diff --git a/client/src/components/courses/CoursesPage.tsx b/client/src/components/courses/CoursesPage.tsx index 8c83d3a..6908620 100644 --- a/client/src/components/courses/CoursesPage.tsx +++ b/client/src/components/courses/CoursesPage.tsx @@ -22,6 +22,7 @@ import { } from "../../utils/api"; import { Course, CoursesResponse } from "../../types"; import { isAuthenticated } from "../../utils/auth"; +import { CardsGridSkeleton } from "../skeletons/CardsGridSkeleton"; const CoursesPage: React.FC = () => { const [courses, setCourses] = useState([]); @@ -277,8 +278,8 @@ const CoursesPage: React.FC = () => { {/* Loading State */} {loading && ( -
-
+
+
)} diff --git a/client/src/components/discussions/DiscussionDetailPage.tsx b/client/src/components/discussions/DiscussionDetailPage.tsx index f1bf4a4..e8954d5 100644 --- a/client/src/components/discussions/DiscussionDetailPage.tsx +++ b/client/src/components/discussions/DiscussionDetailPage.tsx @@ -34,6 +34,7 @@ import { DISCUSSION_CATEGORIES } from "../../types/discussions"; import { getUserProfile } from "../../utils/api"; import MentionInput from "./MentionInput"; import { useSocket } from "../../contexts/SocketContext"; +import { Skeleton } from "../ui/Skeleton"; const DiscussionDetailPage: React.FC = () => { const { id } = useParams<{ id: string }>(); @@ -527,10 +528,78 @@ const DiscussionDetailPage: React.FC = () => { if (loading) { return ( -
-
-
-

Loading discussion...

+
+
+
+
+ + +
+
+ +
+
+
+
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+
+
+ +
+ +
+ +
+ + +
+
+
+ +
+ + {Array.from({ length: 3 }).map((_, i) => ( +
+
+ + + +
+ +
+ +
+ + +
+
+
+ ))} +
); diff --git a/client/src/components/discussions/DiscussionsPage.tsx b/client/src/components/discussions/DiscussionsPage.tsx index bec6928..aaf830e 100644 --- a/client/src/components/discussions/DiscussionsPage.tsx +++ b/client/src/components/discussions/DiscussionsPage.tsx @@ -22,6 +22,7 @@ import { getDiscussions, getPopularTags } from "../../utils/api"; import type { Discussion } from "../../types/discussions"; import { DISCUSSION_CATEGORIES } from "../../types/discussions"; import { useDebounce } from "../../hooks/useDebounce"; +import { ListSkeleton } from "../skeletons/ListSkeleton"; const DiscussionsPage: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); @@ -176,10 +177,26 @@ const DiscussionsPage: React.FC = () => { if (loading && discussions.length === 0) { return ( -
-
-
-

Loading discussions...

+
+
+
+
+

+ Discussion Forum +

+

+ Ask questions, share knowledge, and help your peers +

+
+ + + Ask Question + +
+
); diff --git a/client/src/components/layout/Navbar.tsx b/client/src/components/layout/Navbar.tsx index 4a44c53..1546cfc 100644 --- a/client/src/components/layout/Navbar.tsx +++ b/client/src/components/layout/Navbar.tsx @@ -20,6 +20,7 @@ import AdminNavLink from "../admin/AdminNavLink"; import { getUserProfile, logout } from "../../utils/api"; import type { User as UserType } from "../../types"; import { removeAuthToken } from "../../utils/auth"; +import NotificationDropdown from "../ui/NotificationDropdown"; interface NavbarProps { authenticated: boolean | null; @@ -361,6 +362,9 @@ export default function ResponsiveNavbar({ )}
+ {/* ✅ Notifications */} + {authenticated && } + {/* Auth area (preserved) */} {authenticated ? (
diff --git a/client/src/components/resources/EbooksPage.tsx b/client/src/components/resources/EbooksPage.tsx index a23c431..259fb7f 100644 --- a/client/src/components/resources/EbooksPage.tsx +++ b/client/src/components/resources/EbooksPage.tsx @@ -20,6 +20,7 @@ import { getEbooks } from "../../utils/api"; import { EbookItem } from "../../types"; import { isAuthenticated } from "../../utils/auth"; import { useDebounce } from "../../hooks/useDebounce"; +import { CardsGridSkeleton } from "../skeletons/CardsGridSkeleton"; // Memoized Ebook Card Component const EbookCard = React.memo(({ ebook }: { ebook: EbookItem }) => ( @@ -228,10 +229,18 @@ const EbooksPage: React.FC = () => { if (isInitialLoad && loading) { return ( -
-
-
-

Loading E-books...

+
+
+
+
+

+ E-book Library +

+

Explore digital books and references

+
+
+ +
); diff --git a/client/src/components/resources/PDFsPage.tsx b/client/src/components/resources/PDFsPage.tsx index d200076..9759c4e 100644 --- a/client/src/components/resources/PDFsPage.tsx +++ b/client/src/components/resources/PDFsPage.tsx @@ -20,6 +20,7 @@ import { getPDFs } from "../../utils/api"; import { PDFItem } from "../../types"; import { isAuthenticated } from "../../utils/auth"; import { useDebounce } from "../../hooks/useDebounce"; +import { CardsGridSkeleton } from "../skeletons/CardsGridSkeleton"; // Memoized PDF Card Component const PDFCard = React.memo(({ pdf }: { pdf: PDFItem }) => ( @@ -228,10 +229,29 @@ const PDFsPage: React.FC = () => { if (isInitialLoad && loading) { return ( -
-
-
-

Loading PDFs...

+
+
+
+
+

+ PDF Collection +

+

+ Discover and download academic resources +

+
+ {isAuth && isAdmin && ( + + + Upload PDF + Upload + + )} +
+
); diff --git a/client/src/components/roadmaps/RoadmapPage.tsx b/client/src/components/roadmaps/RoadmapPage.tsx index 78edfed..39ddf5f 100644 --- a/client/src/components/roadmaps/RoadmapPage.tsx +++ b/client/src/components/roadmaps/RoadmapPage.tsx @@ -15,6 +15,7 @@ import { getRoadmaps, toggleRoadmapBookmark } from "../../utils/api"; import { Roadmap, RoadmapsResponse } from "../../types"; import SEO from "../seo/SEO"; import { isAuthenticated } from "../../utils/auth"; +import { RoadmapSkeleton } from "../skeletons/RoadmapSkeleton"; const RoadmapsPage: React.FC = () => { const [roadmaps, setRoadmaps] = useState([]); @@ -197,11 +198,11 @@ const RoadmapsPage: React.FC = () => { {/* Loading State */} {loading && ( -
-
+
+
)} - + {/* Roadmaps Grid */} {!loading && ( <> diff --git a/client/src/components/skeletons/CardsGridSkeleton.tsx b/client/src/components/skeletons/CardsGridSkeleton.tsx new file mode 100644 index 0000000..6b0cab3 --- /dev/null +++ b/client/src/components/skeletons/CardsGridSkeleton.tsx @@ -0,0 +1,26 @@ +import { Skeleton } from "../ui/Skeleton"; + +export function CardsGridSkeleton({ count = 9 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ + +
+ + +
+
+ ))} +
+ ); +} diff --git a/client/src/components/skeletons/ListSkeleton.tsx b/client/src/components/skeletons/ListSkeleton.tsx new file mode 100644 index 0000000..c48bdf7 --- /dev/null +++ b/client/src/components/skeletons/ListSkeleton.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "../ui/Skeleton"; + +export function ListSkeleton({ count = 10 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+ +
+ + +
+
+
+ ))} +
+ ); +} diff --git a/client/src/components/skeletons/RoadmapSkeleton.tsx b/client/src/components/skeletons/RoadmapSkeleton.tsx new file mode 100644 index 0000000..f54efed --- /dev/null +++ b/client/src/components/skeletons/RoadmapSkeleton.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from "../ui/Skeleton"; + +export function RoadmapSkeleton({ count = 6 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+ +
+ + + +
+
+
+ ))} +
+ ); +} diff --git a/client/src/components/ui/NotificationDropdown.tsx b/client/src/components/ui/NotificationDropdown.tsx index 1cc1df7..4ae874b 100644 --- a/client/src/components/ui/NotificationDropdown.tsx +++ b/client/src/components/ui/NotificationDropdown.tsx @@ -15,124 +15,171 @@ import { markNotificationAsRead, markAllNotificationsAsRead, } from "../../utils/api"; -import { Notification } from "../../types/discussions"; -import useSocket from "../../hooks/useSocket"; +import type { Notification as AppNotification } from "../../types/discussions"; +import { useSocket } from "../../contexts/SocketContext"; const NotificationDropdown: React.FC = () => { - const [notifications, setNotifications] = useState([]); + const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [isOpen, setIsOpen] = useState(false); const [loading, setLoading] = useState(false); const dropdownRef = useRef(null); const navigate = useNavigate(); const socket = useSocket(); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loadingMore, setLoadingMore] = useState(false); useEffect(() => { - fetchNotifications(); - - // Poll for new notifications every 30 seconds - const interval = setInterval(fetchNotifications, 30000); - return () => clearInterval(interval); - }, []); + if (isOpen) fetchNotifications(1); + }, [isOpen]); // Listen for real-time notifications useEffect(() => { if (!socket) return; - socket.on("new_notification", (notification: Notification) => { - setNotifications((prev) => [notification, ...prev]); - setUnreadCount((prev) => prev + 1); + const handler = (notification: AppNotification) => { + setNotifications((prev) => { + // const exists = prev.some((n) => n.id === notification.id); + const nid = getNotifId(notification); + const exists = prev.some((n: any) => getNotifId(n) === nid); + + if (exists) return prev; + + if (!notification.is_read) { + setUnreadCount((c) => c + 1); + } - // Show browser notification if permission granted - if (Notification.permission === "granted") { - new Notification(notification.title, { + return [notification, ...prev]; + }); + + if (window.Notification?.permission === "granted") { + new window.Notification(notification.title, { body: notification.message, icon: "/logo.png", }); } - }); + }; + + socket.on("new_notification", handler); return () => { - socket.off("new_notification"); + socket.off("new_notification", handler); }; }, [socket]); + + // Close dropdown when clicking outside useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { + function handleClickOutside(event: MouseEvent) { if ( dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { setIsOpen(false); } - }; + } document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; + return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - const fetchNotifications = async () => { + useEffect(() => { + fetchNotifications(1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const getNotifId = (n: any) => (n?.id ?? n?._id)?.toString(); + + const fetchNotifications = async (pageToFetch: number = 1) => { try { - setLoading(true); - const response = await getNotifications(); - setNotifications(response.notifications); - setUnreadCount(response.unreadCount); + if (pageToFetch === 1) setLoading(true); + else setLoadingMore(true); + + const response = await getNotifications(pageToFetch); + + if (response.pagination?.pages) { + setTotalPages(response.pagination.pages); + } + + if (pageToFetch === 1) { + setNotifications(response.notifications); + } else { + setNotifications((prev) => [...prev, ...response.notifications]); + } + + setUnreadCount( + typeof response.unreadCount === "number" ? response.unreadCount : 0 + ); + + setPage(pageToFetch); // Request notification permission if not already granted - if (Notification.permission === "default") { - Notification.requestPermission(); + if (window.Notification?.permission === "default") { + window.Notification.requestPermission(); } } catch (error) { - console.error("Failed to fetch notifications:", error); // Don't show error to user for notifications, just log it + console.error("Error fetching notifications:", error); } finally { - setLoading(false); + if (pageToFetch === 1) setLoading(false); + else setLoadingMore(false); } }; - const handleNotificationClick = async (notification: Notification) => { + const markAsRead = async (notificationId?: string) => { + if (!notificationId) return; try { - if (!notification.is_read) { - await markNotificationAsRead(notification.id.toString()); - setUnreadCount((prev) => Math.max(0, prev - 1)); - setNotifications((prev) => - prev.map((n) => (n.id === notification.id ? { ...n, is_read: 1 } : n)) - ); - } + await markNotificationAsRead(notificationId); - // Navigate to the related discussion - if ( - notification.related_id && - notification.related_type === "discussion" - ) { - navigate(`/discussions/${notification.related_id}`); - setIsOpen(false); - } + setNotifications((prevNotifications) => + prevNotifications.map((notification) => + getNotifId(notification) === notificationId + ? { ...notification, is_read: true } + : notification + ) + ); + + setUnreadCount((prevCount) => Math.max(0, prevCount - 1)); } catch (error) { - console.error("Failed to mark notification as read:", error); + console.error("Error marking notification as read:", error); } }; - const handleMarkAllAsRead = async () => { + const markAllAsRead = async () => { try { setLoading(true); await markAllNotificationsAsRead(); + + setNotifications((prevNotifications) => + prevNotifications.map((notification) => ({ ...notification, is_read: true })) + ); + setUnreadCount(0); - setNotifications((prev) => prev.map((n) => ({ ...n, is_read: 1 }))); } catch (error) { - console.error("Failed to mark all notifications as read:", error); + console.error("Error marking all notifications as read:", error); } finally { setLoading(false); } }; + const handleNotificationClick = async (notification: AppNotification) => { + if (!notification.is_read) { + await markAsRead(getNotifId(notification)); + } + + // Navigate to the related discussion + if (notification.related_type === "discussion" && notification.related_id) { + navigate(`/discussions/${notification.related_id}`); + setIsOpen(false); + } + }; + const getNotificationIcon = (type: string) => { switch (type) { case "new_answer": return ; case "mention": - return ; + return ; case "best_answer": return ; case "reply": @@ -160,27 +207,28 @@ const NotificationDropdown: React.FC = () => {
{isOpen && ( -
+
{/* Header */}

Notifications

{unreadCount > 0 && ( @@ -188,6 +236,7 @@ const NotificationDropdown: React.FC = () => { @@ -196,52 +245,52 @@ const NotificationDropdown: React.FC = () => { {/* Notifications List */}
- {notifications.length === 0 ? ( -
- -

No notifications yet

+ {loading && page === 1 ? ( +
+ Loading notifications... +
+ ) : notifications.length === 0 ? ( +
+ No notifications
) : ( notifications.map((notification) => (
handleNotificationClick(notification)} - className={`px-4 py-3 border-b border-smoke-light/50 cursor-pointer hover:bg-smoke-light/30 transition-colors duration-300 ${ - !notification.is_read - ? "bg-alien-green/5 border-l-2 border-l-alien-green" - : "" + className={`px-4 py-3 border-b border-smoke-light cursor-pointer hover:bg-smoke-light/30 transition-colors duration-300 ${ + !notification.is_read ? "bg-smoke-light/10" : "" }`} >
-
- {getNotificationIcon(notification.type)} -
+
{getNotificationIcon(notification.type)}
-
-

+

+

{notification.title}

{!notification.is_read && ( -
+ )}
-

+

{notification.message}

-
-

+

+ {formatTimeAgo(notification.created_at)} -

- {notification.from_username && ( -

- @{notification.from_username} -

+
+ {!notification.is_read && ( + )}
@@ -249,22 +298,19 @@ const NotificationDropdown: React.FC = () => {
)) )} -
- {/* Footer */} - {notifications.length > 0 && ( -
- -
- )} + {page < totalPages && ( +
+ +
+ )} +
)}
diff --git a/client/src/components/ui/Skeleton.tsx b/client/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..ba965ed --- /dev/null +++ b/client/src/components/ui/Skeleton.tsx @@ -0,0 +1,20 @@ +import { cn } from "../../lib/utils"; + +export function Skeleton({ className = "" }: { className?: string }) { + return ( +
+ +
+ ); +} diff --git a/client/src/types/discussions.ts b/client/src/types/discussions.ts index a015bb1..a4ddde7 100644 --- a/client/src/types/discussions.ts +++ b/client/src/types/discussions.ts @@ -58,7 +58,7 @@ export interface Notification { related_type?: "discussion" | "answer"; from_user_id?: number; from_username?: string; - is_read: number; + is_read: boolean; created_at: string; } diff --git a/client/src/utils/api.ts b/client/src/utils/api.ts index c87720e..9fca64b 100644 --- a/client/src/utils/api.ts +++ b/client/src/utils/api.ts @@ -474,11 +474,19 @@ export const voteReply = async ( return response.data; }; -export const getNotifications = async (): Promise<{ +export const getNotifications = async (pageToFetch:number=1): Promise<{ notifications: Notification[]; unreadCount: number; + pagination?:{ + page:number; + limit:number; + total:number; + pages:number; + } }> => { - const response = await api.get("/notifications"); + const response = await api.get("/notifications",{ + params:{page:pageToFetch,limit:20} + }); return response.data; }; diff --git a/python-backend/.gitignore b/python-backend/.gitignore index 62c10fb..edc0fff 100644 --- a/python-backend/.gitignore +++ b/python-backend/.gitignore @@ -20,4 +20,5 @@ Thumbs.db # Jupyter Notebook checkpoints .ipynb_checkpoints/ -# Virtual environments \ No newline at end of file +# Virtual environments +venv \ No newline at end of file diff --git a/server/routes/notifications.js b/server/routes/notifications.js index a4ae74b..d4ea70e 100644 --- a/server/routes/notifications.js +++ b/server/routes/notifications.js @@ -11,17 +11,20 @@ router.get("/", authenticateToken, async (req, res) => { const skip = (parseInt(page) - 1) * parseInt(limit); const take = parseInt(limit); - const [notifications, total] = await Promise.all([ - prisma.notification.findMany({ - where: { userId: req.user.id }, - orderBy: { createdAt: "desc" }, - skip, - take, - }), - prisma.notification.count({ - where: { userId: req.user.id }, - }), - ]); + const [notifications, total,unreadCount] = await Promise.all([ + prisma.notification.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: "desc" }, + skip, + take, + }), + prisma.notification.count({ + where: { userId: req.user.id }, + }), + prisma.notification.count({ + where: { userId: req.user.id, isRead: false }, + }), + ]); // Transform to match expected format const transformedNotifications = notifications.map((notification) => ({ @@ -37,6 +40,7 @@ router.get("/", authenticateToken, async (req, res) => { res.json({ notifications: transformedNotifications, + unreadCount, pagination: { page: parseInt(page), limit: parseInt(limit),