Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
f524dc8fd1
commit
ca5b7ea01a
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Bell, Check, CheckCheck } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NotificationRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
link: string | null;
|
||||
task_id: string | null;
|
||||
case_id: string | null;
|
||||
conversation_id: string | null;
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState<NotificationRow[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!user) return;
|
||||
const { data } = await supabase
|
||||
.from("notifications")
|
||||
.select("*")
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
setNotifications((data ?? []) as NotificationRow[]);
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
load();
|
||||
const ch = supabase
|
||||
.channel(`notif-${user.id}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "notifications", filter: `user_id=eq.${user.id}` },
|
||||
() => load(),
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(ch);
|
||||
};
|
||||
}, [user, load]);
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read_at).length;
|
||||
|
||||
const markRead = async (id: string) => {
|
||||
await supabase.from("notifications").update({ read_at: new Date().toISOString() }).eq("id", id);
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
if (!user) return;
|
||||
await supabase
|
||||
.from("notifications")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.eq("user_id", user.id)
|
||||
.is("read_at", null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-4 min-w-4 px-1 rounded-full bg-destructive text-destructive-foreground text-[10px] font-bold flex items-center justify-center">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 p-0">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<div className="font-semibold text-sm">Notifications</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={markAllRead}>
|
||||
<CheckCheck className="h-3.5 w-3.5 mr-1" /> Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="max-h-[420px]">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
You're all caught up.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{notifications.map((n) => {
|
||||
const inner = (
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2.5 border-b hover:bg-accent/60 cursor-pointer flex gap-2",
|
||||
!n.read_at && "bg-primary/5",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!n.read_at) markRead(n.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={cn("h-2 w-2 rounded-full mt-1.5 shrink-0", !n.read_at ? "bg-primary" : "bg-transparent")} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{n.title}</div>
|
||||
{n.body && (
|
||||
<div className="text-xs text-muted-foreground truncate">{n.body}</div>
|
||||
)}
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(new Date(n.created_at), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
{!n.read_at && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markRead(n.id);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Mark read"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return n.link ? (
|
||||
<Link key={n.id} to={n.link as any}>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={n.id}>{inner}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user