Notifications in the UI

This commit is contained in:
Arthur Belleville 2025-11-16 14:01:41 +01:00
parent 766364def4
commit cd309d30df
No known key found for this signature in database
8 changed files with 1223 additions and 704 deletions

View file

@ -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>

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

View file

@ -164,6 +164,7 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
members={members}
initialStatus={selectedTask?.status ?? "todo"}
/>
</div>
);

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

View file

@ -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"
}
}

View file

@ -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

View file

@ -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