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 {
|
||||
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>
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -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",
|
||||
|
||||
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