diff --git a/src/app/me/components/ActivityTimeline.tsx b/src/app/me/components/ActivityTimeline.tsx index 50b04f0..8059c10 100644 --- a/src/app/me/components/ActivityTimeline.tsx +++ b/src/app/me/components/ActivityTimeline.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { formatDistanceToNow } from "date-fns"; import { History, @@ -35,7 +36,7 @@ const ICON_MAP: Record @@ -93,4 +94,4 @@ export function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) { ); -} \ No newline at end of file +}); \ No newline at end of file diff --git a/src/app/me/components/MeSidebar.tsx b/src/app/me/components/MeSidebar.tsx index 11c1408..cccfff5 100644 --- a/src/app/me/components/MeSidebar.tsx +++ b/src/app/me/components/MeSidebar.tsx @@ -7,6 +7,7 @@ import { Star, Users, MessageSquare, + Bell, PanelLeftClose } from "lucide-react"; import type { ComponentType } from "react"; @@ -47,6 +48,11 @@ const ME_NAV_ITEMS: NavItem[] = [ name: "Chat", icon: MessageSquare, }, + { + path: "/me/notify", + name: "Notifications", + icon: Bell, + }, { path: "/me/stars", name: "Stars", diff --git a/src/app/me/components/NotificationList.tsx b/src/app/me/components/NotificationList.tsx new file mode 100644 index 0000000..9889465 --- /dev/null +++ b/src/app/me/components/NotificationList.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { notificationList, notificationMarkRead, notificationMarkAllRead } from "@/client/api"; +import type { NotificationResponse } from "@/client/model"; +import { Bell, CheckCheck, Mail, MailOpen, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +const NOTIFICATION_TYPE_LABELS: Record = { + mention: "Mention", + invitation: "Invitation", + role_change: "Role Change", + room_created: "Room Created", + room_deleted: "Room Deleted", + system_announcement: "Announcement", + project_invitation: "Project Invitation", +}; + +function NotificationItem({ notification }: { notification: NotificationResponse }) { + const queryClient = useQueryClient(); + const markReadMutation = useMutation({ + mutationFn: () => notificationMarkRead(notification.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notificationList"] }); + }, + }); + + return ( +
{ + if (!notification.is_read) { + markReadMutation.mutate(); + } + }} + > +
+
+ {notification.is_read ? ( + + ) : ( + + )} +
+
+
+ + {NOTIFICATION_TYPE_LABELS[notification.notification_type] || notification.notification_type} + + {!notification.is_read && ( + + )} + + {new Date(notification.created_at).toLocaleString()} + +
+

+ {notification.title} +

+ {notification.content && ( +

+ {notification.content} +

+ )} + {(notification.room || notification.project) && ( +
+ {notification.project && Project: {notification.project}} + {notification.room && Room: {notification.room}} +
+ )} +
+
+
+ ); +} + +export function NotificationList() { + const queryClient = useQueryClient(); + const [onlyUnread, setOnlyUnread] = useState(false); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["notificationList", onlyUnread], + queryFn: () => notificationList({ only_unread: onlyUnread || undefined, limit: 50 }), + }); + + const markAllReadMutation = useMutation({ + mutationFn: notificationMarkAllRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notificationList"] }); + }, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+

Failed to load notifications

+
+ ); + } + + const notifications = data?.data?.data?.notifications ?? []; + const unreadCount = data?.data?.data?.unread_count ?? 0; + + return ( +
+ {/* Toolbar */} +
+
+ + + {unreadCount > 0 ? `${unreadCount} unread` : "All read"} + +
+
+ + {unreadCount > 0 && ( + + )} +
+
+ + {notifications.length === 0 ? ( +
+ +

No notifications

+

+ {onlyUnread ? "No unread notifications" : "You're all caught up"} +

+
+ ) : ( +
+ {notifications.map((n) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file