292 lines
9.8 KiB
TypeScript
292 lines
9.8 KiB
TypeScript
import { Badge } from "@xtablo/ui/components/badge";
|
|
import { Button } from "@xtablo/ui/components/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@xtablo/ui/components/dropdown-menu";
|
|
import {
|
|
TypographyLarge,
|
|
TypographyMuted,
|
|
TypographySmall,
|
|
} from "@xtablo/ui/components/typography";
|
|
import {
|
|
BellIcon,
|
|
CheckCheckIcon,
|
|
CalendarIcon,
|
|
FileTextIcon,
|
|
KanbanIcon,
|
|
LayoutDashboardIcon,
|
|
UserPlusIcon,
|
|
MailIcon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import { useEffect } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import { useNotifications, useNotificationsSubscription } from "../hooks/notifications";
|
|
import type { Database } from "@xtablo/shared-types";
|
|
import { twMerge } from "tailwind-merge";
|
|
|
|
type Notification = Database["public"]["Tables"]["notifications"]["Row"];
|
|
|
|
// Get icon based on entity type
|
|
function getNotificationIcon(entityType: string) {
|
|
switch (entityType) {
|
|
case "tablos":
|
|
return <LayoutDashboardIcon className="w-4 h-4" />;
|
|
case "tasks":
|
|
return <KanbanIcon className="w-4 h-4" />;
|
|
case "events":
|
|
return <CalendarIcon className="w-4 h-4" />;
|
|
case "notes":
|
|
return <FileTextIcon className="w-4 h-4" />;
|
|
case "tablo_access":
|
|
return <UserPlusIcon className="w-4 h-4" />;
|
|
case "tablo_invites":
|
|
return <MailIcon className="w-4 h-4" />;
|
|
default:
|
|
return <BellIcon className="w-4 h-4" />;
|
|
}
|
|
}
|
|
|
|
// Get link to entity
|
|
function getNotificationLink(notification: Notification): string {
|
|
const { entity_type, entity_id, metadata } = notification;
|
|
|
|
switch (entity_type) {
|
|
case "tablos":
|
|
return `/tablos/${entity_id}`;
|
|
case "tasks":
|
|
// If we have tablo_id in metadata, link to the tablo
|
|
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
|
|
return `/tablos/${metadata.tablo_id}`;
|
|
}
|
|
return "/";
|
|
case "events":
|
|
// If we have tablo_id in metadata, link to the tablo events
|
|
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
|
|
return `/tablos/${metadata.tablo_id}`;
|
|
}
|
|
return "/events";
|
|
case "notes":
|
|
return `/notes/${entity_id}`;
|
|
case "tablo_access":
|
|
case "tablo_invites":
|
|
// If we have tablo_id in metadata, link to the tablo
|
|
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
|
|
return `/tablos/${metadata.tablo_id}`;
|
|
}
|
|
return "/";
|
|
default:
|
|
return "/";
|
|
}
|
|
}
|
|
|
|
// Format relative time
|
|
function formatRelativeTime(dateString: string): string {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
|
|
if (diffInSeconds < 60) {
|
|
return "Just now";
|
|
}
|
|
|
|
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
|
if (diffInMinutes < 60) {
|
|
return `${diffInMinutes}m ago`;
|
|
}
|
|
|
|
const diffInHours = Math.floor(diffInMinutes / 60);
|
|
if (diffInHours < 24) {
|
|
return `${diffInHours}h ago`;
|
|
}
|
|
|
|
const diffInDays = Math.floor(diffInHours / 24);
|
|
if (diffInDays < 7) {
|
|
return `${diffInDays}d ago`;
|
|
}
|
|
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
interface NotificationItemProps {
|
|
notification: Notification;
|
|
onMarkAsRead: (id: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function NotificationItem({ notification, onMarkAsRead, onClose }: NotificationItemProps) {
|
|
const link = getNotificationLink(notification);
|
|
|
|
const handleClick = () => {
|
|
onMarkAsRead(notification.id);
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<Link to={link} onClick={handleClick}>
|
|
<DropdownMenuItem className="cursor-pointer p-3 focus:bg-gray-700 hover:bg-gray-700 text-gray-200">
|
|
<div className="flex gap-3 w-full">
|
|
<div className="shrink-0 mt-1">
|
|
<div className="p-2 rounded-full bg-blue-900/50 text-blue-400">
|
|
{getNotificationIcon(notification.entity_type)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<TypographySmall className="font-medium text-gray-100 line-clamp-2">
|
|
{notification.message}
|
|
</TypographySmall>
|
|
<TypographyMuted className="text-xs mt-1 text-gray-400">
|
|
{formatRelativeTime(notification.created_at)}
|
|
</TypographyMuted>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-600 text-gray-300 hover:text-white"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onMarkAsRead(notification.id);
|
|
}}
|
|
>
|
|
<XIcon className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export function NotificationPanel({ isCollapsed }: { isCollapsed: boolean }) {
|
|
const { t } = useTranslation("navigation");
|
|
const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead } = useNotifications();
|
|
const { setupSubscription } = useNotificationsSubscription();
|
|
|
|
// Setup real-time subscription
|
|
useEffect(() => {
|
|
const cleanup = setupSubscription();
|
|
return cleanup;
|
|
}, []);
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className={twMerge(
|
|
"flex items-center justify-start hover:bg-navbar-darker w-full h-auto min-h-8 pl-2.5 gap-2",
|
|
isCollapsed && "justify-center px-2"
|
|
)}
|
|
aria-label="Notifications"
|
|
size="lg"
|
|
>
|
|
{unreadCount > 0 ? (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-linear-to-br from-red-400 to-red-500 text-white font-semibold text-xs shadow-lg shadow-red-500/50 ring-2 ring-red-400/30 ring-offset-1 ring-offset-navbar-background">
|
|
{unreadCount > 9 ? "9+" : unreadCount}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<BellIcon className="text-gray-300" />
|
|
)}
|
|
{!isCollapsed && (
|
|
<TypographyLarge
|
|
className={twMerge(
|
|
"text-sm transition-all duration-300 font-normal text-gray-300/90"
|
|
// location.pathname === "/feedback" ? "text-white" : "text-gray-300/90",
|
|
// isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
|
|
)}
|
|
>
|
|
{t("notifications.title", "Notifications")}
|
|
</TypographyLarge>
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
className="min-w-96 bg-navbar-background border-gray-600/50 p-1 rounded-lg text-white"
|
|
side="right"
|
|
align="end"
|
|
sideOffset={-8}
|
|
>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-600/50">
|
|
<div className="flex items-center gap-2">
|
|
<TypographySmall className="font-semibold text-gray-100">
|
|
{t("notifications.title", "Notifications")}
|
|
</TypographySmall>
|
|
{unreadCount > 0 && (
|
|
<Badge className="bg-red-500 text-white text-xs">{unreadCount}</Badge>
|
|
)}
|
|
</div>
|
|
{unreadCount > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 text-xs text-gray-200 hover:text-white hover:bg-gray-700"
|
|
onClick={() => markAllAsRead()}
|
|
>
|
|
<CheckCheckIcon className="h-3 w-3 mr-1" />
|
|
{t("notifications.markAllRead", "Mark all read")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="max-h-[400px] overflow-y-auto">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<TypographyMuted className="text-sm text-gray-300">
|
|
{t("notifications.loading", "Loading...")}
|
|
</TypographyMuted>
|
|
</div>
|
|
) : notifications.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
|
<div className="p-3 rounded-full bg-gray-700 mb-3">
|
|
<BellIcon className="h-6 w-6 text-gray-400" />
|
|
</div>
|
|
<TypographySmall className="font-medium text-gray-100 mb-1">
|
|
{t("notifications.noNotifications", "No new notifications")}
|
|
</TypographySmall>
|
|
<TypographyMuted className="text-xs text-gray-400">
|
|
{t("notifications.allCaughtUp", "You're all caught up!")}
|
|
</TypographyMuted>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-600/50">
|
|
{notifications.map((notification) => (
|
|
<div key={notification.id} className="group">
|
|
<NotificationItem
|
|
notification={notification}
|
|
onMarkAsRead={markAsRead}
|
|
onClose={() => {
|
|
// Close dropdown - handled by Link navigation
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* {notifications.length > 0 && (
|
|
<>
|
|
<DropdownMenuSeparator className="bg-gray-600/50" />
|
|
<div className="px-4 py-2">
|
|
<Link to="/notifications" className="block w-full">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full text-sm text-gray-200 hover:text-white hover:bg-gray-700"
|
|
>
|
|
{t("notifications.viewAll", "View all notifications")}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</>
|
|
)} */}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|