feat(phase-18): add in-app notification system (T-128, T-129)
This commit is contained in:
51
frontend/src/api/notifications.ts
Normal file
51
frontend/src/api/notifications.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import client from "./client";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string | null;
|
||||
entity_type: string | null;
|
||||
entity_id: string | null;
|
||||
read: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface UnreadCount {
|
||||
unread_count: number;
|
||||
}
|
||||
|
||||
// ── API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch notifications for the current user (paginated). */
|
||||
export async function getNotifications(
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
): Promise<NotificationItem[]> {
|
||||
const { data } = await client.get<NotificationItem[]>(
|
||||
`/notifications?offset=${offset}&limit=${limit}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Get the unread notification count. */
|
||||
export async function getUnreadCount(): Promise<UnreadCount> {
|
||||
const { data } = await client.get<UnreadCount>("/notifications/unread-count");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Mark a single notification as read. */
|
||||
export async function markAsRead(id: string): Promise<NotificationItem> {
|
||||
const { data } = await client.patch<NotificationItem>(
|
||||
`/notifications/${id}/read`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Mark all notifications as read. */
|
||||
export async function markAllAsRead(): Promise<void> {
|
||||
await client.post("/notifications/read-all");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Outlet } from "react-router-dom";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Sidebar from "./Sidebar";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -13,6 +14,7 @@ export default function Layout() {
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6">
|
||||
<NotificationBell />
|
||||
<span className="text-sm text-gray-300">{user?.username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
53
frontend/src/components/NotificationBell.tsx
Normal file
53
frontend/src/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bell } from "lucide-react";
|
||||
import { getUnreadCount } from "../api/notifications";
|
||||
import NotificationDropdown from "./NotificationDropdown";
|
||||
|
||||
export default function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["notifications", "unread-count"],
|
||||
queryFn: getUnreadCount,
|
||||
refetchInterval: 30000, // Poll every 30 seconds
|
||||
});
|
||||
|
||||
const count = data?.unread_count ?? 0;
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(!open);
|
||||
if (!open) {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
}
|
||||
}}
|
||||
className="relative rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{count > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && <NotificationDropdown onClose={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
frontend/src/components/NotificationDropdown.tsx
Normal file
139
frontend/src/components/NotificationDropdown.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCheck,
|
||||
FlaskConical,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
type NotificationItem,
|
||||
} from "../api/notifications";
|
||||
|
||||
const typeIcons: Record<string, React.ReactNode> = {
|
||||
test_assigned: <FlaskConical className="h-4 w-4 text-indigo-400" />,
|
||||
validation_needed: <AlertTriangle className="h-4 w-4 text-yellow-400" />,
|
||||
test_rejected: <XCircle className="h-4 w-4 text-red-400" />,
|
||||
test_validated: <CheckCircle className="h-4 w-4 text-green-400" />,
|
||||
test_state_changed: <Bell className="h-4 w-4 text-cyan-400" />,
|
||||
};
|
||||
|
||||
export default function NotificationDropdown({ onClose }: { onClose: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: notifications, isLoading } = useQuery({
|
||||
queryKey: ["notifications", "list"],
|
||||
queryFn: () => getNotifications(0, 20),
|
||||
});
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: markAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
},
|
||||
});
|
||||
|
||||
const markAllMutation = useMutation({
|
||||
mutationFn: markAllAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleClick = (notif: NotificationItem) => {
|
||||
if (!notif.read) {
|
||||
markReadMutation.mutate(notif.id);
|
||||
}
|
||||
if (notif.entity_type === "test" && notif.entity_id) {
|
||||
navigate(`/tests/${notif.entity_id}`);
|
||||
} else if (notif.entity_type === "technique" && notif.entity_id) {
|
||||
navigate(`/techniques/${notif.entity_id}`);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string | null) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffH = Math.floor(diffMin / 60);
|
||||
if (diffH < 24) return `${diffH}h ago`;
|
||||
const diffD = Math.floor(diffH / 24);
|
||||
return `${diffD}d ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-gray-800 bg-gray-900 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-800 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-white">Notifications</h3>
|
||||
<button
|
||||
onClick={() => markAllMutation.mutate()}
|
||||
disabled={markAllMutation.isPending}
|
||||
className="flex items-center gap-1 text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
) : notifications && notifications.length > 0 ? (
|
||||
notifications.map((notif) => (
|
||||
<button
|
||||
key={notif.id}
|
||||
onClick={() => handleClick(notif)}
|
||||
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-gray-800/50 ${
|
||||
!notif.read ? "bg-cyan-500/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
{typeIcons[notif.type] || <Bell className="h-4 w-4 text-gray-400" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`text-sm ${
|
||||
notif.read ? "text-gray-400" : "font-medium text-white"
|
||||
}`}
|
||||
>
|
||||
{notif.title}
|
||||
</p>
|
||||
{notif.message && (
|
||||
<p className="mt-0.5 text-xs text-gray-500 truncate">
|
||||
{notif.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-gray-600">
|
||||
{formatTime(notif.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{!notif.read && (
|
||||
<div className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-cyan-400" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
No notifications yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user