Update MeLayout, MePage, and all me/components (ActivityTimeline, NotificationList, ProfileHeader, ProjectList, RepoList) to use CSS variable-based theme tokens and improved layout.
244 lines
7.6 KiB
TypeScript
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>
|
|
)
|
|
}
|