feat(me): add ActivityTimeline and NotificationList components
Add ActivityTimeline component with user activity display and NotificationList for user notifications.
This commit is contained in:
parent
e64dc94d29
commit
9981664731
@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from "react";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import {
|
import {
|
||||||
History,
|
History,
|
||||||
@ -35,7 +36,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string; "aria-h
|
|||||||
avatar_upload: ImageIcon,
|
avatar_upload: ImageIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
|
export const ActivityTimeline = memo(function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flow-root">
|
<div className="flow-root">
|
||||||
@ -93,4 +94,4 @@ export function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Users,
|
Users,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Bell,
|
||||||
PanelLeftClose
|
PanelLeftClose
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
@ -47,6 +48,11 @@ const ME_NAV_ITEMS: NavItem[] = [
|
|||||||
name: "Chat",
|
name: "Chat",
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/me/notify",
|
||||||
|
name: "Notifications",
|
||||||
|
icon: Bell,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/me/stars",
|
path: "/me/stars",
|
||||||
name: "Stars",
|
name: "Stars",
|
||||||
|
|||||||
172
src/app/me/components/NotificationList.tsx
Normal file
172
src/app/me/components/NotificationList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user