249 lines
8.2 KiB
TypeScript
249 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useSocket } from "@/components/socket-provider";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Bell, CheckCheck, ArrowLeft } from "lucide-react";
|
|
import { useGetNotifications } from "@/features/notifications/api/use-get-notifications";
|
|
import { useMarkNotificationsRead } from "@/features/notifications/api/use-mark-notifications-read";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { toast } from "sonner";
|
|
|
|
interface Notification {
|
|
id: string;
|
|
type: "MENTION" | "TASK_ASSIGNED" | "WORKSPACE_ADDED" | "COMMENT_REPLY";
|
|
title: string;
|
|
message: string;
|
|
isRead: boolean;
|
|
createdAt: string;
|
|
task?: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
comment?: {
|
|
id: string;
|
|
content: string;
|
|
};
|
|
workspace: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
data?: any;
|
|
}
|
|
|
|
export default function NotificationsPage() {
|
|
const router = useRouter();
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const { socket } = useSocket();
|
|
|
|
const { data: notifications = [], refetch } = useGetNotifications({});
|
|
const { mutate: markAsRead, isPending } = useMarkNotificationsRead();
|
|
|
|
const unreadNotifications = notifications.filter((n: Notification) => !n.isRead);
|
|
const readNotifications = notifications.filter((n: Notification) => n.isRead);
|
|
|
|
// Listen for real-time notifications
|
|
useEffect(() => {
|
|
if (socket) {
|
|
const handleNewNotification = () => {
|
|
refetch();
|
|
};
|
|
|
|
socket.on("notification", handleNewNotification);
|
|
|
|
return () => {
|
|
socket.off("notification", handleNewNotification);
|
|
};
|
|
}
|
|
}, [socket, refetch]);
|
|
|
|
const handleNotificationClick = (notification: Notification) => {
|
|
// Mark as read if not already read
|
|
if (!notification.isRead) {
|
|
markAsRead(
|
|
{ json: { notificationIds: [notification.id] } },
|
|
{
|
|
onSuccess: () => {
|
|
refetch();
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
// Navigate to the relevant page
|
|
if (notification.task) {
|
|
if (notification.comment) {
|
|
// Navigate to task and scroll to comment
|
|
router.push(`/workspaces/${notification.workspace.id}/tasks/${notification.task.id}?comment=${notification.comment.id}`);
|
|
} else {
|
|
// Navigate to task
|
|
router.push(`/workspaces/${notification.workspace.id}/tasks/${notification.task.id}`);
|
|
}
|
|
} else if (notification.type === "WORKSPACE_ADDED") {
|
|
// Navigate to workspace
|
|
router.push(`/workspaces/${notification.workspace.id}`);
|
|
}
|
|
};
|
|
|
|
const handleMarkAllAsRead = () => {
|
|
const unreadIds = unreadNotifications.map((n: Notification) => n.id);
|
|
if (unreadIds.length === 0) {
|
|
toast.info("No unread notifications");
|
|
return;
|
|
}
|
|
|
|
markAsRead(
|
|
{ json: { notificationIds: unreadIds } },
|
|
{
|
|
onSuccess: () => {
|
|
refetch();
|
|
toast.success("All notifications marked as read");
|
|
},
|
|
}
|
|
);
|
|
};
|
|
|
|
const getNotificationIcon = (type: string) => {
|
|
switch (type) {
|
|
case "MENTION":
|
|
return "@";
|
|
case "TASK_ASSIGNED":
|
|
return "📋";
|
|
case "COMMENT_REPLY":
|
|
return "💬";
|
|
case "WORKSPACE_ADDED":
|
|
return "🏢";
|
|
default:
|
|
return "🔔";
|
|
}
|
|
};
|
|
|
|
const getNotificationTypeColor = (type: string) => {
|
|
switch (type) {
|
|
case "MENTION":
|
|
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
|
|
case "TASK_ASSIGNED":
|
|
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
|
|
case "COMMENT_REPLY":
|
|
return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300";
|
|
case "WORKSPACE_ADDED":
|
|
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300";
|
|
default:
|
|
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
|
|
}
|
|
};
|
|
|
|
const renderNotification = (notification: Notification) => (
|
|
<Card
|
|
key={notification.id}
|
|
className={`cursor-pointer transition-colors hover:bg-accent ${
|
|
!notification.isRead ? "border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20" : ""
|
|
}`}
|
|
onClick={() => handleNotificationClick(notification)}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-start gap-3 flex-1">
|
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm">
|
|
{getNotificationIcon(notification.type)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h4 className="font-medium text-sm truncate">{notification.title}</h4>
|
|
{!notification.isRead && (
|
|
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" />
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-2">{notification.message}</p>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary" className={getNotificationTypeColor(notification.type)}>
|
|
{notification.type.replace("_", " ").toLowerCase()}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 max-w-4xl">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back
|
|
</Button>
|
|
<div className="flex items-center gap-2">
|
|
<Bell className="h-6 w-6" />
|
|
<h1 className="text-2xl font-semibold">All Notifications</h1>
|
|
{unreadNotifications.length > 0 && (
|
|
<Badge variant="destructive">{unreadNotifications.length}</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{unreadNotifications.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleMarkAllAsRead}
|
|
disabled={isPending}
|
|
>
|
|
<CheckCheck className="h-4 w-4 mr-2" />
|
|
Mark all as read
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{unreadNotifications.length > 0 && (
|
|
<div>
|
|
<h2 className="text-lg font-medium mb-3 flex items-center gap-2">
|
|
Unread ({unreadNotifications.length})
|
|
<div className="w-2 h-2 bg-blue-500 rounded-full" />
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{unreadNotifications.map(renderNotification)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{unreadNotifications.length > 0 && readNotifications.length > 0 && (
|
|
<Separator />
|
|
)}
|
|
|
|
{readNotifications.length > 0 && (
|
|
<div>
|
|
<h2 className="text-lg font-medium mb-3 text-muted-foreground">
|
|
Read ({readNotifications.length})
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{readNotifications.map(renderNotification)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{notifications.length === 0 && (
|
|
<Card>
|
|
<CardContent className="p-8 text-center">
|
|
<Bell className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
|
<h3 className="text-lg font-medium mb-2">No notifications yet</h3>
|
|
<p className="text-muted-foreground">
|
|
You'll see notifications here when you're mentioned, assigned tasks, or added to workspaces.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|