102 lines
3.5 KiB
TypeScript
102 lines
3.5 KiB
TypeScript
import { Bell, Check, ChevronRight } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { api } from "@/client";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useAuth } from "@/context/auth-context";
|
|
|
|
interface NotificationItem {
|
|
id: string;
|
|
title: string;
|
|
body: string;
|
|
read_at?: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export default function MeNotificationsPage() {
|
|
const { me } = useAuth();
|
|
const unreadCount = me?.has_unread_notifications ?? 0;
|
|
|
|
const { data: notifications, isLoading } = useQuery({
|
|
queryKey: ["user", "notifications"],
|
|
queryFn: async () => {
|
|
const res = await api.get<NotificationItem[]>("/api/v1/ws/notifications");
|
|
return res.data;
|
|
},
|
|
retry: false,
|
|
});
|
|
|
|
return (
|
|
<div className="mx-auto max-w-2xl px-8 py-10">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-heading font-bold text-foreground">Notifications</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{unreadCount > 0 ? `${unreadCount} unread` : "All caught up"}
|
|
</p>
|
|
</div>
|
|
{unreadCount > 0 && (
|
|
<Button className="rounded-lg border-border text-muted-foreground hover:text-foreground" variant="outline">
|
|
<Check className="size-4" />
|
|
Mark all read
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-6 space-y-1">
|
|
{isLoading ? (
|
|
Array.from({ length: 3 }).map((_, i) => (
|
|
<div className="h-16 animate-pulse rounded-lg bg-muted" key={i} />
|
|
))
|
|
) : (notifications ?? []).length > 0 ? (
|
|
notifications!.map((notif: NotificationItem) => (
|
|
<div
|
|
className="flex items-start gap-3 rounded-lg px-4 py-4 transition-colors hover:bg-accent/50 cursor-pointer"
|
|
key={notif.id}
|
|
>
|
|
<span
|
|
className={`mt-1 size-2 rounded-full shrink-0 ${
|
|
notif.read_at ? "bg-muted-foreground/30" : "bg-primary"
|
|
}`}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p
|
|
className={`font-heading font-medium text-sm ${
|
|
notif.read_at ? "text-muted-foreground" : "text-foreground"
|
|
}`}
|
|
>
|
|
{notif.title}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">{notif.body}</p>
|
|
</div>
|
|
<span className="font-mono text-xs text-muted-foreground shrink-0 flex items-center gap-1">
|
|
{formatTime(notif.created_at)}
|
|
<ChevronRight className="size-3" />
|
|
</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="mt-16 text-center">
|
|
<Bell className="mx-auto size-6 text-muted-foreground/30" />
|
|
<p className="mt-4 text-sm text-muted-foreground">No notifications yet</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatTime(dateStr: string): string {
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diff = now.getTime() - date.getTime();
|
|
const minutes = Math.floor(diff / 60000);
|
|
const hours = Math.floor(diff / 3600000);
|
|
const days = Math.floor(diff / 86400000);
|
|
|
|
if (minutes < 1) return "just now";
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
if (days < 7) return `${days}d ago`;
|
|
return date.toLocaleDateString();
|
|
}
|