feat(me): add ActivityTimeline and NotificationList components

Add ActivityTimeline component with user activity display and
NotificationList for user notifications.
This commit is contained in:
ZhenYi 2026-05-14 21:50:18 +08:00
parent e64dc94d29
commit 9981664731
3 changed files with 181 additions and 2 deletions

View File

@ -1,3 +1,4 @@
import { memo } from "react";
import { formatDistanceToNow } from "date-fns";
import {
History,
@ -35,7 +36,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string; "aria-h
avatar_upload: ImageIcon,
};
export function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
export const ActivityTimeline = memo(function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
if (isLoading) {
return (
<div className="flow-root">
@ -93,4 +94,4 @@ export function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
</ul>
</div>
);
}
});

View File

@ -7,6 +7,7 @@ import {
Star,
Users,
MessageSquare,
Bell,
PanelLeftClose
} from "lucide-react";
import type { ComponentType } from "react";
@ -47,6 +48,11 @@ const ME_NAV_ITEMS: NavItem[] = [
name: "Chat",
icon: MessageSquare,
},
{
path: "/me/notify",
name: "Notifications",
icon: Bell,
},
{
path: "/me/stars",
name: "Stars",

View File

@ -0,0 +1,172 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { notificationList, notificationMarkRead, notificationMarkAllRead } from "@/client/api";
import type { NotificationResponse } from "@/client/model";
import { Bell, CheckCheck, Mail, MailOpen, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
mention: "Mention",
invitation: "Invitation",
role_change: "Role Change",
room_created: "Room Created",
room_deleted: "Room Deleted",
system_announcement: "Announcement",
project_invitation: "Project Invitation",
};
function NotificationItem({ notification }: { notification: NotificationResponse }) {
const queryClient = useQueryClient();
const markReadMutation = useMutation({
mutationFn: () => notificationMarkRead(notification.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationList"] });
},
});
return (
<div
className="p-4 rounded-xl border-[0.5px] transition-all"
style={{
backgroundColor: notification.is_read ? "var(--surface-secondary)" : "var(--surface-tertiary)",
borderColor: "var(--border-subtle)",
opacity: notification.is_read ? 0.75 : 1,
}}
onClick={() => {
if (!notification.is_read) {
markReadMutation.mutate();
}
}}
>
<div className="flex items-start gap-3">
<div
className="mt-0.5 w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: notification.is_read ? "var(--surface-ground)" : "var(--accent)" }}
>
{notification.is_read ? (
<MailOpen className="w-4 h-4" style={{ color: "var(--text-muted)" }} />
) : (
<Mail className="w-4 h-4 text-white" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[12px] font-medium" style={{ color: "var(--text-primary)" }}>
{NOTIFICATION_TYPE_LABELS[notification.notification_type] || notification.notification_type}
</span>
{!notification.is_read && (
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: "var(--accent)" }} />
)}
<span className="text-[11px] ml-auto shrink-0" style={{ color: "var(--text-tertiary)" }}>
{new Date(notification.created_at).toLocaleString()}
</span>
</div>
<p className="text-[13px] font-medium truncate" style={{ color: "var(--text-primary)" }}>
{notification.title}
</p>
{notification.content && (
<p className="text-[12px] mt-1 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
{notification.content}
</p>
)}
{(notification.room || notification.project) && (
<div className="flex items-center gap-2 mt-2 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
{notification.project && <span>Project: {notification.project}</span>}
{notification.room && <span>Room: {notification.room}</span>}
</div>
)}
</div>
</div>
</div>
);
}
export function NotificationList() {
const queryClient = useQueryClient();
const [onlyUnread, setOnlyUnread] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: ["notificationList", onlyUnread],
queryFn: () => notificationList({ only_unread: onlyUnread || undefined, limit: 50 }),
});
const markAllReadMutation = useMutation({
mutationFn: notificationMarkAllRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationList"] });
},
});
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: "var(--accent)" }} />
</div>
);
}
if (isError) {
return (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
<p>Failed to load notifications</p>
</div>
);
}
const notifications = data?.data?.data?.notifications ?? [];
const unreadCount = data?.data?.data?.unread_count ?? 0;
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4" style={{ color: "var(--text-tertiary)" }} />
<span className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
{unreadCount > 0 ? `${unreadCount} unread` : "All read"}
</span>
</div>
<div className="flex items-center gap-2">
<button
className="text-[12px] px-2.5 py-1 rounded-md transition-colors"
style={{
color: onlyUnread ? "var(--accent)" : "var(--text-secondary)",
backgroundColor: onlyUnread ? "color-mix(in srgb, var(--accent) 10%, transparent)" : "transparent",
}}
onClick={() => setOnlyUnread(!onlyUnread)}
>
Unread only
</button>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="text-[12px] h-7 gap-1"
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
>
<CheckCheck className="w-3.5 h-3.5" />
Mark all read
</Button>
)}
</div>
</div>
{notifications.length === 0 ? (
<div className="text-center py-16" style={{ color: "var(--text-muted)" }}>
<Bell className="w-10 h-10 mx-auto mb-3" style={{ color: "var(--text-tertiary)" }} />
<p className="text-[14px] font-medium">No notifications</p>
<p className="text-[12px] mt-1">
{onlyUnread ? "No unread notifications" : "You're all caught up"}
</p>
</div>
) : (
<div className="space-y-2">
{notifications.map((n) => (
<NotificationItem key={n.id} notification={n} />
))}
</div>
)}
</div>
);
}