SPB Git

Repository Public

notifications.tsx 4.8 KB 99 lines tsx

1'use client';2import Link from 'next/link';3import * as Popover from '@radix-ui/react-popover';4import { Bell, CheckCheck } from 'lucide-react';5import { useQueryClient } from '@tanstack/react-query';6import type { Notification } from '@cr/shared';7import { api, errorMessage } from '@/lib/api';8import { qk, useNotifications } from '@/lib/queries';9import { useUi } from '@/stores/ui';10import { useNow } from '@/hooks/use-viewport';11import { fmtRelative } from '@/lib/format';12import { Button } from '@/components/ui/button';13import { EmptyState, ErrorState } from '@/components/ui/misc';14import { Dot, type Tone } from '@/components/ui/badge';15import { toast } from '@/stores/toasts';16import { cn } from '@/lib/cn';1718const TONE: Record<Notification['kind'], Tone> = {19  'session.waiting': 'warning', 'session.exited': 'neutral', 'task.succeeded': 'success', 'task.failed': 'error', 'node.offline': 'warning', 'storage.low': 'warning', system: 'accent',20};2122export function NotificationsBell({ className }: { className?: string }) {23  const open = useUi((s) => s.notificationsOpen);24  const setUi = useUi((s) => s.set);25  const q = useNotifications();26  const qc = useQueryClient();27  const now = useNow(30_000);28  const list = q.data ?? [];29  const unread = list.filter((n) => !n.readAt).length;30  const markRead = async (ids?: string[]) => {31    const at = new Date().toISOString();32    qc.setQueryData<Notification[]>(qk.notifications, (l) => l?.map((n) => (!n.readAt && (!ids || ids.includes(n.id)) ? { ...n, readAt: at } : n)));33    try {34      await api.post('/notifications/read', ids ? { ids } : { all: true });35    } catch (e) {36      toast.error('Could not mark as read', errorMessage(e));37      void qc.invalidateQueries({ queryKey: qk.notifications });38    }39  };40  return (41    <Popover.Root open={open} onOpenChange={(o) => setUi({ notificationsOpen: o })}>42      <Popover.Trigger asChild>43        <button44          aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'}45          data-testid="notifications-bell"46          className={cn('relative inline-flex size-8 shrink-0 items-center justify-center rounded-sm text-text-2 hover:bg-surface-2 hover:text-text pointer-coarse:size-11', className)}47        >48          <Bell className="size-4" />49          {unread > 0 && (50            <span className="tnum absolute right-0.5 top-0.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-accent px-0.5 text-[9px] font-bold text-accent-fg pointer-coarse:right-1.5 pointer-coarse:top-1.5">51              {unread > 99 ? '99+' : unread}52            </span>53          )}54        </button>55      </Popover.Trigger>56      <Popover.Portal>57        <Popover.Content align="end" sideOffset={8} collisionPadding={8} className="anim-fade z-[70] flex max-h-[min(70dvh,520px)] w-[min(380px,calc(100vw-16px))] flex-col rounded-md border border-line-2 bg-elevated shadow-pop focus:outline-none">58          <div className="flex items-center gap-2 border-b border-line px-3 py-2">59            <p className="flex-1 text-xs font-semibold">Notifications</p>60            <Button size="sm" variant="ghost" disabled={!unread} onClick={() => markRead()}>61              <CheckCheck className="size-3.5" /> Mark all read62            </Button>63          </div>64          <div className="min-h-0 flex-1 overflow-y-auto p-1">65            {q.isError ? (66              <ErrorState error={q.error} onRetry={() => q.refetch()} />67            ) : list.length === 0 ? (68              <EmptyState title="No notifications" description="Session and task events will appear here." className="py-8" />69            ) : (70              list.map((n) => {71                const inner = (72                  <>73                    <Dot tone={n.readAt ? 'muted' : TONE[n.kind]} className="mt-1.5" />74                    <span className="min-w-0 flex-1">75                      <span className={cn('block text-[13px]', n.readAt ? 'text-text-2' : 'font-medium text-text')}>{n.title}</span>76                      <span className="block text-xs text-text-2">{n.body}</span>77                      <span className="tnum mt-0.5 block text-[11px] text-muted">{fmtRelative(n.createdAt, now)}</span>78                    </span>79                  </>80                );81                const cls = 'flex w-full items-start gap-2.5 rounded-sm px-2 py-2 text-left hover:bg-hover';82                return n.link ? (83                  <Link key={n.id} href={n.link} className={cls} onClick={() => { void markRead([n.id]); setUi({ notificationsOpen: false }); }}>84                    {inner}85                  </Link>86                ) : (87                  <button key={n.id} className={cls} onClick={() => markRead([n.id])}>88                    {inner}89                  </button>90                );91              })92            )}93          </div>94        </Popover.Content>95      </Popover.Portal>96    </Popover.Root>97  );98}99
Files main
Menu

Theme