- Land users on their first accessible page instead of /dashboard, so users without dashboard:view no longer get an "access denied" toast on every sign-in, impersonation switch or unknown URL. - Reset the active workspace and the persisted deploy/minimized/bulk task trays on logout and impersonation, so the next identity in the browser does not see the previous one's tasks or workspace. - Keep the session when /api/auth/me fails with a network error or 5xx (panel restarting) and retry, instead of dropping the token. - Notifications: mark-read/delete buttons no longer close the menu. - Command palette starts every opening with an empty query. - Live instance metrics no longer show the previous instance's data after switching instances. - reset/resume are expected to leave the guest running (no "stopped" flash and no 36 s of pointless polling). - Execute-script goes through apiClient (workspace header, 401 handling, readable validation errors). - Node shell no longer reconnects (re-running apt dist-upgrade) when the translation function changes; fullscreen icon follows Esc exits. - Node power/certificate actions use hasPermission so role-bound admins see them; power toasts are translated.
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
import { useTranslation } from 'react-i18next';
|
|
import { Bell, Check, CheckCheck, Trash2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import type { Notification } from '@/types';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import {
|
|
useNotifications,
|
|
useUnreadCount,
|
|
useMarkRead,
|
|
useMarkAllRead,
|
|
useDeleteNotification,
|
|
} from '@/hooks/use-notifications';
|
|
|
|
export default function NotificationsDropdown() {
|
|
const { t } = useTranslation();
|
|
const { data: notifResponse } = useNotifications();
|
|
const { data: countData } = useUnreadCount();
|
|
const markRead = useMarkRead();
|
|
const markAllRead = useMarkAllRead();
|
|
const deleteNotification = useDeleteNotification();
|
|
|
|
const notifications: Notification[] = notifResponse?.notifications ?? [];
|
|
const unread = countData?.count ?? 0;
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger render={
|
|
<Button variant="ghost" size="icon" className="relative">
|
|
<Bell className="h-4 w-4" />
|
|
{unread > 0 && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="absolute -right-0.5 -top-0.5 h-4 min-w-4 px-1 text-2xs"
|
|
>
|
|
{unread > 99 ? '99+' : unread}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
} />
|
|
<DropdownMenuContent align="end" className="w-80 max-h-96 overflow-y-auto">
|
|
<div className="flex items-center justify-between px-3 py-2">
|
|
<span className="text-sm font-semibold">{t('notifications.title')}</span>
|
|
{unread > 0 && (
|
|
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => markAllRead.mutate()}>
|
|
<CheckCheck className="h-3 w-3 mr-1" />{t('notifications.mark_all_read')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
{notifications.length === 0 ? (
|
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{t('notifications.empty')}</div>
|
|
) : (
|
|
notifications.slice(0, 20).map(n => (
|
|
// closeOnClick={false}: the mark-read / delete buttons live inside the
|
|
// item, so a click on them must not close the whole menu (Base UI has
|
|
// no Radix-style onSelect to cancel).
|
|
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-1 p-3 cursor-default" closeOnClick={false}>
|
|
<div className="flex w-full items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`text-sm leading-tight ${n.is_read ? 'text-muted-foreground' : 'font-medium'}`}>
|
|
{n.title || n.message}
|
|
</p>
|
|
{n.title && n.message && (
|
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{n.message}</p>
|
|
)}
|
|
<p className="text-2xs text-muted-foreground mt-1">
|
|
{n.created_at ? new Date(n.created_at).toLocaleString() : ''}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-0.5 shrink-0">
|
|
{!n.is_read && (
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => markRead.mutate(n.id)}>
|
|
<Check className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={() => deleteNotification.mutate(n.id)}>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|