gitdataai/src/app/me/components/NotificationList.tsx
ZhenYi 16739d3cf8 refactor(ui): update me/profile pages for new theme system
Update MeLayout, MePage, and all me/components (ActivityTimeline,
NotificationList, ProfileHeader, ProjectList, RepoList) to use
CSS variable-based theme tokens and improved layout.
2026-05-18 20:44:29 +08:00

244 lines
7.6 KiB
TypeScript

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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
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 (
<button
type="button"
className="w-full rounded-2xl border text-left transition-all hover:-translate-y-0.5"
style={{
backgroundColor: notification.is_read
? "var(--surface-secondary)"
: "var(--surface-elevated)",
borderColor: "var(--border-subtle)",
opacity: notification.is_read ? 0.85 : 1,
}}
onClick={() => {
if (!notification.is_read) markReadMutation.mutate()
}}
>
<div className="p-4">
<div className="flex items-start gap-3">
<div
className="flex size-8 shrink-0 items-center justify-center rounded-xl"
style={{
backgroundColor: notification.is_read
? "var(--surface-ground)"
: "var(--accent)",
}}
>
{notification.is_read ? (
<MailOpen
className="size-4"
style={{ color: "var(--text-muted)" }}
/>
) : (
<Mail className="size-4 text-white" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<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="size-1.5 rounded-full"
style={{ backgroundColor: "var(--accent)" }}
/>
)}
<span
className="ml-auto text-[11px]"
style={{ color: "var(--text-tertiary)" }}
>
{new Date(notification.created_at).toLocaleString()}
</span>
</div>
<p
className="mt-1 truncate text-[13px] font-medium"
style={{ color: "var(--text-primary)" }}
>
{notification.title}
</p>
{notification.content && (
<p
className="mt-1 line-clamp-2 text-[12px]"
style={{ color: "var(--text-secondary)" }}
>
{notification.content}
</p>
)}
{(notification.room || notification.project) && (
<div className="mt-3 flex flex-wrap gap-2">
{notification.project && (
<Badge variant="outline">{`Project: ${notification.project}`}</Badge>
)}
{notification.room && (
<Badge variant="outline">{`Room: ${notification.room}`}</Badge>
)}
</div>
)}
</div>
</div>
</div>
</button>
)
}
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="size-6 animate-spin"
style={{ color: "var(--accent)" }}
/>
</div>
)
}
if (isError) {
return (
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
<EmptyHeader>
<EmptyMedia variant="icon">
<Bell />
</EmptyMedia>
<EmptyTitle></EmptyTitle>
<EmptyDescription></EmptyDescription>
</EmptyHeader>
</Empty>
)
}
const notifications = data?.data?.data?.notifications ?? []
const unreadCount = data?.data?.data?.unread_count ?? 0
return (
<div className="space-y-4">
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-4">
<div className="flex items-center gap-2">
<Bell
className="size-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
type="button"
className="rounded-full px-3 py-1.5 text-[12px] 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="outline"
size="sm"
className="h-7 gap-1.5 rounded-full text-[12px]"
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
>
<CheckCheck className="size-3.5" />
Mark all read
</Button>
)}
</div>
</CardContent>
</Card>
{notifications.length === 0 ? (
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)] py-14">
<EmptyHeader>
<EmptyMedia variant="icon">
<Bell />
</EmptyMedia>
<EmptyTitle></EmptyTitle>
<EmptyDescription>
{onlyUnread ? "没有未读通知" : "你已经查看完了"}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="space-y-2">
{notifications.map((n) => (
<NotificationItem key={n.id} notification={n} />
))}
</div>
)}
</div>
)
}