gitdataai/src/app/notify/page.tsx
2026-04-15 09:08:09 +08:00

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