feat(notifications): add in-app notification center panel
- add notifications API client - add notifications modal in chat page header - show recent notification events with timestamps and count badge
This commit is contained in:
14
web/src/api/notifications.ts
Normal file
14
web/src/api/notifications.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { http } from "./http";
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
event_type: string;
|
||||||
|
payload: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotifications(limit = 30): Promise<NotificationItem[]> {
|
||||||
|
const { data } = await http.get<NotificationItem[]>("/notifications", { params: { limit } });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { ChatList } from "../components/ChatList";
|
|||||||
import { ChatInfoPanel } from "../components/ChatInfoPanel";
|
import { ChatInfoPanel } from "../components/ChatInfoPanel";
|
||||||
import { MessageComposer } from "../components/MessageComposer";
|
import { MessageComposer } from "../components/MessageComposer";
|
||||||
import { MessageList } from "../components/MessageList";
|
import { MessageList } from "../components/MessageList";
|
||||||
|
import { getNotifications, type NotificationItem } from "../api/notifications";
|
||||||
import { searchMessages } from "../api/chats";
|
import { searchMessages } from "../api/chats";
|
||||||
import type { Message } from "../chat/types";
|
import type { Message } from "../chat/types";
|
||||||
import { useRealtime } from "../hooks/useRealtime";
|
import { useRealtime } from "../hooks/useRealtime";
|
||||||
@@ -26,6 +27,9 @@ export function ChatsPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<Message[]>([]);
|
const [searchResults, setSearchResults] = useState<Message[]>([]);
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
|
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||||
|
const [loadingNotifications, setLoadingNotifications] = useState(false);
|
||||||
|
|
||||||
useRealtime();
|
useRealtime();
|
||||||
|
|
||||||
@@ -72,6 +76,33 @@ export function ChatsPage() {
|
|||||||
};
|
};
|
||||||
}, [searchOpen, searchQuery, activeChatId]);
|
}, [searchOpen, searchQuery, activeChatId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notificationsOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingNotifications(true);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const items = await getNotifications(30);
|
||||||
|
if (!cancelled) {
|
||||||
|
setNotifications(items);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setNotifications([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoadingNotifications(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [notificationsOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="h-screen w-full p-2 text-text md:p-4">
|
<main className="h-screen w-full p-2 text-text md:p-4">
|
||||||
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-4">
|
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-4">
|
||||||
@@ -96,6 +127,15 @@ export function ChatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="relative rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80"
|
||||||
|
onClick={() => setNotificationsOpen(true)}
|
||||||
|
>
|
||||||
|
Notifications
|
||||||
|
{notifications.length > 0 ? (
|
||||||
|
<span className="ml-1 rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] text-slate-950">{notifications.length}</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={() => setSearchOpen(true)}>
|
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={() => setSearchOpen(true)}>
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
@@ -161,6 +201,26 @@ export function ChatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{notificationsOpen ? (
|
||||||
|
<div className="fixed inset-0 z-[130] flex items-start justify-center bg-slate-950/60 p-3 md:p-6" onClick={() => setNotificationsOpen(false)}>
|
||||||
|
<div className="w-full max-w-xl rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold">Notifications</p>
|
||||||
|
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={() => setNotificationsOpen(false)}>Close</button>
|
||||||
|
</div>
|
||||||
|
{loadingNotifications ? <p className="px-2 py-1 text-xs text-slate-400">Loading...</p> : null}
|
||||||
|
{!loadingNotifications && notifications.length === 0 ? <p className="px-2 py-1 text-xs text-slate-400">No notifications</p> : null}
|
||||||
|
<div className="tg-scrollbar max-h-[50vh] space-y-1 overflow-auto">
|
||||||
|
{notifications.map((item) => (
|
||||||
|
<div className="rounded-lg bg-slate-800/80 px-3 py-2" key={item.id}>
|
||||||
|
<p className="text-xs font-semibold text-slate-200">{item.event_type}</p>
|
||||||
|
<p className="text-[11px] text-slate-400">{new Date(item.created_at).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user