xtablo-source/apps/main/src/components/NotificationPanel.tsx
2025-11-16 14:01:41 +01:00

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>
);
}