Notifications in the UI
This commit is contained in:
parent
766364def4
commit
cd309d30df
8 changed files with 1223 additions and 704 deletions
|
|
@ -37,6 +37,7 @@ import { useLogout } from "../hooks/auth";
|
|||
import { isProd, isStaging } from "../lib/env";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
import { getXtabloIcon } from "../utils/iconHelpers";
|
||||
import { NotificationPanel } from "./NotificationPanel";
|
||||
import { ThemeSwitcher } from "./ThemeSwitcher";
|
||||
|
||||
type NavLinkItem = {
|
||||
|
|
@ -264,10 +265,11 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
|
|||
<MainNavigation isCollapsed={isCollapsed} />
|
||||
<div
|
||||
className={twMerge(
|
||||
"bg-navbar-background flex px-1 pb-1.5 w-full mt-auto",
|
||||
"bg-navbar-background flex flex-col px-1 pb-1.5 w-full mt-auto gap-1",
|
||||
isCollapsed ? "pl-2.5 pr-3.5" : ""
|
||||
)}
|
||||
>
|
||||
<NotificationPanel isCollapsed={isCollapsed} />
|
||||
<UserMenuPopover isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
</nav>
|
||||
|
|
@ -433,6 +435,9 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
</div>
|
||||
</RouterLink>
|
||||
</NavLink>
|
||||
<li className="my-2">
|
||||
<Separator className="border-gray-300/20" />
|
||||
</li>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
|||
292
apps/main/src/components/NotificationPanel.tsx
Normal file
292
apps/main/src/components/NotificationPanel.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -164,6 +164,7 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
|
|||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
members={members}
|
||||
initialStatus={selectedTask?.status ?? "todo"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
119
apps/main/src/hooks/notifications.ts
Normal file
119
apps/main/src/hooks/notifications.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import type { Database } from "@xtablo/shared-types";
|
||||
|
||||
type Notification = Database["public"]["Tables"]["notifications"]["Row"];
|
||||
|
||||
export function useNotifications() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch unread notifications
|
||||
const { data: notifications = [], isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["notifications", "unread"],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("notifications")
|
||||
.select("*")
|
||||
.is("read_at", null)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data as Notification[];
|
||||
},
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
});
|
||||
|
||||
// Mark notification as read
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: async (notificationId: string) => {
|
||||
const { error } = await supabase
|
||||
.from("notifications")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.eq("id", notificationId);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Mark all notifications as read
|
||||
const markAllAsReadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { error } = await supabase
|
||||
.from("notifications")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.is("read_at", null);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount: notifications.length,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
markAsRead: markAsReadMutation.mutate,
|
||||
markAllAsRead: markAllAsReadMutation.mutate,
|
||||
isMarkingAsRead: markAsReadMutation.isPending || markAllAsReadMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
// Hook to fetch all notifications (including read ones)
|
||||
export function useAllNotifications(limit = 50) {
|
||||
const { data: notifications = [], isLoading, error } = useQuery({
|
||||
queryKey: ["notifications", "all", limit],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("notifications")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) throw error;
|
||||
return data as Notification[];
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
notifications,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
// Real-time subscription hook
|
||||
export function useNotificationsSubscription() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setupSubscription = () => {
|
||||
const channel = supabase
|
||||
.channel("notifications")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "*",
|
||||
schema: "public",
|
||||
table: "notifications",
|
||||
},
|
||||
() => {
|
||||
// Invalidate and refetch notifications when changes occur
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
};
|
||||
|
||||
return { setupSubscription };
|
||||
}
|
||||
|
||||
|
|
@ -14,5 +14,13 @@
|
|||
"settings": "Settings",
|
||||
"availabilities": "Availabilities",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"markAllRead": "Mark all read",
|
||||
"loading": "Loading...",
|
||||
"noNotifications": "No new notifications",
|
||||
"allCaughtUp": "You're all caught up!",
|
||||
"viewAll": "View all notifications"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,5 +14,13 @@
|
|||
"settings": "Paramètres",
|
||||
"availabilities": "Disponibilités",
|
||||
"logout": "Se déconnecter"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"markAllRead": "Tout marquer comme lu",
|
||||
"loading": "Chargement...",
|
||||
"noNotifications": "Aucune nouvelle notification",
|
||||
"allCaughtUp": "Vous êtes à jour !",
|
||||
"viewAll": "Voir toutes les notifications"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -345,6 +345,45 @@ export type Database = {
|
|||
}
|
||||
Relationships: []
|
||||
}
|
||||
notifications: {
|
||||
Row: {
|
||||
action_type: string
|
||||
actor_id: string | null
|
||||
created_at: string
|
||||
entity_id: string
|
||||
entity_type: string
|
||||
id: string
|
||||
message: string
|
||||
metadata: Json | null
|
||||
read_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
action_type: string
|
||||
actor_id?: string | null
|
||||
created_at?: string
|
||||
entity_id: string
|
||||
entity_type: string
|
||||
id?: string
|
||||
message: string
|
||||
metadata?: Json | null
|
||||
read_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
action_type?: string
|
||||
actor_id?: string | null
|
||||
created_at?: string
|
||||
entity_id?: string
|
||||
entity_type?: string
|
||||
id?: string
|
||||
message?: string
|
||||
metadata?: Json | null
|
||||
read_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profiles: {
|
||||
Row: {
|
||||
avatar_url: string | null
|
||||
|
|
|
|||
Loading…
Reference in a new issue