Files
pvemanager/frontend/src/components/shared/NotificationsDropdown.tsx
markmorado 990ca14c63 fix(frontend): routing, session state, console and power-action bugs
- 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.
2026-10-23 01:51:53 +05:00

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>
);
}