291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Archive,
|
|
AtSign,
|
|
Bell,
|
|
BellOff,
|
|
Check,
|
|
CheckCheck,
|
|
Loader2,
|
|
Mail,
|
|
MessageSquare,
|
|
Shield,
|
|
} from "lucide-react";
|
|
import { notificationList, notificationMarkRead, notificationMarkAllRead, notificationArchive } from "@/client";
|
|
import type { NotificationResponse } from "@/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {getApiErrorMessage} from '@/lib/api-error';
|
|
|
|
type Filter = "all" | "unread" | "archived";
|
|
|
|
const NOTIFICATION_TYPE_CONFIG: Record<
|
|
string,
|
|
{ label: string; icon: React.ReactNode; color: string }
|
|
> = {
|
|
mention: { label: "Mention", icon: <AtSign className="h-3.5 w-3.5" />, color: "bg-blue-500/10 text-blue-600 border-blue-500/20" },
|
|
invitation: { label: "Invitation", icon: <Mail className="h-3.5 w-3.5" />, color: "bg-purple-500/10 text-purple-600 border-purple-500/20" },
|
|
role_change: { label: "Role Change", icon: <Shield className="h-3.5 w-3.5" />, color: "bg-orange-500/10 text-orange-600 border-orange-500/20" },
|
|
room_created: { label: "Room Created", icon: <MessageSquare className="h-3.5 w-3.5" />, color: "bg-green-500/10 text-green-600 border-green-500/20" },
|
|
room_deleted: { label: "Room Deleted", icon: <MessageSquare className="h-3.5 w-3.5" />, color: "bg-red-500/10 text-red-600 border-red-500/20" },
|
|
system_announcement: { label: "Announcement", icon: <Bell className="h-3.5 w-3.5" />, color: "bg-yellow-500/10 text-yellow-700 border-yellow-500/20" },
|
|
};
|
|
|
|
function formatTime(dateStr: string): string {
|
|
const d = new Date(dateStr);
|
|
const now = new Date();
|
|
const diff = now.getTime() - d.getTime();
|
|
const minutes = Math.floor(diff / 60000);
|
|
if (minutes < 1) return "just now";
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 7) return `${days}d ago`;
|
|
return d.toLocaleDateString();
|
|
}
|
|
|
|
function NotificationItem({
|
|
n,
|
|
onMarkRead,
|
|
onArchive,
|
|
}: {
|
|
n: NotificationResponse;
|
|
onMarkRead: (id: string) => void;
|
|
onArchive: (id: string) => void;
|
|
}) {
|
|
const config = NOTIFICATION_TYPE_CONFIG[n.notification_type] ?? {
|
|
label: n.notification_type,
|
|
icon: <Bell className="h-3.5 w-3.5" />,
|
|
color: "bg-muted text-muted-foreground border-border",
|
|
};
|
|
|
|
const handleClick = () => {
|
|
if (!n.is_read) onMarkRead(n.id);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`group flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors border-b last:border-b-0 ${
|
|
!n.is_read ? "bg-primary/5" : ""
|
|
}`}
|
|
>
|
|
{/* Unread dot */}
|
|
<div className="flex-shrink-0 pt-1">
|
|
{!n.is_read && <div className="h-2 w-2 rounded-full bg-primary" />}
|
|
</div>
|
|
|
|
{/* Icon */}
|
|
<div className={`flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border flex items-center justify-center ${config.color}`}>
|
|
{config.icon}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleClick}
|
|
className="text-left flex-1 min-w-0"
|
|
>
|
|
<p className={`text-sm truncate ${!n.is_read ? "font-semibold" : "font-medium"}`}>
|
|
{n.title}
|
|
</p>
|
|
{n.content && (
|
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
|
{n.content}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-2 mt-1.5">
|
|
<Badge variant="outline" className={`text-xs ${config.color} border`}>
|
|
{config.label}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatTime(n.created_at)}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{!n.is_read && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 w-7 p-0"
|
|
onClick={(e) => { e.stopPropagation(); onMarkRead(n.id); }}
|
|
title="Mark as read"
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
|
onClick={(e) => { e.stopPropagation(); onArchive(n.id); }}
|
|
title="Archive"
|
|
>
|
|
<Archive className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function NotifyPage() {
|
|
const queryClient = useQueryClient();
|
|
const [filter, setFilter] = useState<Filter>("all");
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["notifications", filter],
|
|
queryFn: async () => {
|
|
const resp = await notificationList({
|
|
query: {
|
|
only_unread: filter === "unread",
|
|
archived: filter === "archived" ? true : undefined,
|
|
limit: 100,
|
|
},
|
|
});
|
|
return resp.data?.data ?? null;
|
|
},
|
|
});
|
|
|
|
const markReadMutation = useMutation({
|
|
mutationFn: async (notificationId: string) => {
|
|
await notificationMarkRead({ path: { notification_id: notificationId } });
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
|
},
|
|
onError: (err: unknown) => {
|
|
toast.error(getApiErrorMessage(err, "Failed to mark as read"));
|
|
},
|
|
});
|
|
|
|
const markAllReadMutation = useMutation({
|
|
mutationFn: async () => {
|
|
await notificationMarkAllRead();
|
|
},
|
|
onSuccess: () => {
|
|
toast.success("All notifications marked as read");
|
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
|
},
|
|
onError: (err: unknown) => {
|
|
toast.error(getApiErrorMessage(err, "Failed to mark all as read"));
|
|
},
|
|
});
|
|
|
|
const archiveMutation = useMutation({
|
|
mutationFn: async (notificationId: string) => {
|
|
await notificationArchive({ path: { notification_id: notificationId } });
|
|
},
|
|
onSuccess: () => {
|
|
toast.success("Notification archived");
|
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
|
},
|
|
onError: (err: unknown) => {
|
|
toast.error(getApiErrorMessage(err, "Failed to archive"));
|
|
},
|
|
});
|
|
|
|
const notifications: NotificationResponse[] = data?.notifications ?? [];
|
|
const total: number = data?.total ?? 0;
|
|
const unreadCount: number = data?.unread_count ?? 0;
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-semibold flex items-center gap-2">
|
|
<Bell className="h-5 w-5" />
|
|
Notifications
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
{unreadCount > 0
|
|
? `${unreadCount} unread · ${total} total`
|
|
: `${total} notification${total !== 1 ? "s" : ""}`}
|
|
</p>
|
|
</div>
|
|
{unreadCount > 0 && filter !== "archived" && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => markAllReadMutation.mutate()}
|
|
disabled={markAllReadMutation.isPending}
|
|
>
|
|
{markAllReadMutation.isPending ? (
|
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
) : (
|
|
<CheckCheck className="h-3.5 w-3.5 mr-1.5" />
|
|
)}
|
|
Mark all read
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filter tabs */}
|
|
<div className="flex items-center gap-1 border-b">
|
|
{(["all", "unread", "archived"] as Filter[]).map((f) => (
|
|
<button
|
|
key={f}
|
|
type="button"
|
|
onClick={() => setFilter(f)}
|
|
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
|
filter === f
|
|
? "border-primary text-primary"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{f === "all" ? "All" : f === "unread" ? "Unread" : "Archived"}
|
|
{f === "unread" && unreadCount > 0 && (
|
|
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white px-1">
|
|
{unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div className="border rounded-lg bg-card overflow-hidden">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center h-48">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : notifications.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
|
<BellOff className="h-10 w-10 mb-3 opacity-40" />
|
|
<p className="font-medium">
|
|
{filter === "unread" ? "No unread notifications" : filter === "archived" ? "No archived notifications" : "No notifications yet"}
|
|
</p>
|
|
<p className="text-sm mt-1">
|
|
{filter === "unread"
|
|
? "You're all caught up!"
|
|
: filter === "archived"
|
|
? "Archived notifications will appear here."
|
|
: "You'll see notifications here when something happens."}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
notifications.map((n) => (
|
|
<NotificationItem
|
|
key={n.id}
|
|
n={n}
|
|
onMarkRead={(id) => markReadMutation.mutate(id)}
|
|
onArchive={(id) => archiveMutation.mutate(id)}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|