|
| 1 | +import { useMemo, useState } from 'react' |
| 2 | +import { Popover } from '@cloudflare/kumo' |
| 3 | +import { ArrowRight, Pulse } from '@phosphor-icons/react' |
| 4 | +import type { RpcStub } from 'capnweb' |
| 5 | +import type { ActionLogEntry, Overseer } from '@gadgets/workshop-shared/api' |
| 6 | +import { CountBadge } from './components/CountBadge' |
| 7 | +import { ResolveButton } from './components/ResolveButton' |
| 8 | +import { formatRelativeTime, type ActivityView } from './Activity' |
| 9 | +import { useResolveAction } from './useResolveAction' |
| 10 | + |
| 11 | +interface ActivityNotificationsProps { |
| 12 | + overseer: RpcStub<Overseer> |
| 13 | + pendingActions: ActionLogEntry[] |
| 14 | + onViewActivity: (view: ActivityView) => void |
| 15 | +} |
| 16 | + |
| 17 | +const PREVIEW_LIMIT = 3 |
| 18 | + |
| 19 | +export default function ActivityNotifications({ |
| 20 | + overseer, |
| 21 | + pendingActions, |
| 22 | + onViewActivity, |
| 23 | +}: ActivityNotificationsProps) { |
| 24 | + const [open, setOpen] = useState(false) |
| 25 | + const [processing, setProcessing] = useState<Set<number>>(new Set()) |
| 26 | + const resolveAction = useResolveAction(overseer, setProcessing) |
| 27 | + |
| 28 | + const pending = useMemo(() => pendingActions.toSorted((a, b) => |
| 29 | + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() || a.id - b.id), |
| 30 | + [pendingActions]) |
| 31 | + |
| 32 | + const openFullView = (view: ActivityView) => { |
| 33 | + setOpen(false) |
| 34 | + onViewActivity(view) |
| 35 | + } |
| 36 | + |
| 37 | + return ( |
| 38 | + <Popover open={open} onOpenChange={setOpen}> |
| 39 | + <Popover.Trigger |
| 40 | + render={ |
| 41 | + <button |
| 42 | + type="button" |
| 43 | + aria-label={pending.length > 0 |
| 44 | + ? `Activity — ${pending.length} ${pending.length === 1 ? 'request needs' : 'requests need'} review` |
| 45 | + : 'Activity'} |
| 46 | + className={`relative flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors duration-150 hover:bg-kumo-tint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-ring ${ |
| 47 | + pending.length > 0 ? 'text-kumo-strong' : 'text-kumo-subtle hover:text-kumo-default' |
| 48 | + }`} |
| 49 | + > |
| 50 | + <Pulse size={16} weight={pending.length > 0 ? 'bold' : 'regular'} /> |
| 51 | + <CountBadge count={pending.length} tone="solid" className="absolute -right-0.5 -top-0.5" /> |
| 52 | + </button> |
| 53 | + } |
| 54 | + /> |
| 55 | + {/* Kumo always renders base-ui's arrow as the popup's first child; hide it so this sits flush |
| 56 | + like the header's profile menu, which has no arrow. */} |
| 57 | + <Popover.Content |
| 58 | + align="end" |
| 59 | + sideOffset={8} |
| 60 | + positionMethod="fixed" |
| 61 | + className="themed-floating-shadow !z-[1100] !w-[min(340px,calc(100vw-24px))] !min-w-0 overflow-hidden rounded-lg border border-kumo-line !outline-none bg-kumo-base !p-0 [&>:first-child]:hidden" |
| 62 | + > |
| 63 | + <div className="flex items-center justify-between gap-2 px-3.5 pb-1 pt-2.5"> |
| 64 | + <Popover.Title className="text-[11px] font-medium uppercase tracking-[0.06em] text-kumo-inactive"> |
| 65 | + Needs review |
| 66 | + </Popover.Title> |
| 67 | + <CountBadge count={pending.length} /> |
| 68 | + </div> |
| 69 | + |
| 70 | + {pending.length === 0 ? ( |
| 71 | + <p className="m-0 px-3.5 pb-3 pt-1 text-[13px] leading-[18px] tracking-[-0.25px] text-kumo-subtle"> |
| 72 | + Nothing is waiting on you. |
| 73 | + </p> |
| 74 | + ) : ( |
| 75 | + <div className="max-h-[min(58vh,420px)] overflow-y-auto pb-1"> |
| 76 | + {pending.slice(0, PREVIEW_LIMIT).map((action, index) => { |
| 77 | + const isProcessing = processing.has(action.id) |
| 78 | + return ( |
| 79 | + <div |
| 80 | + key={action.id} |
| 81 | + className={`px-3.5 py-2.5 ${index === 0 ? '' : 'border-t border-kumo-line'}`} |
| 82 | + > |
| 83 | + <div className="flex items-start gap-2"> |
| 84 | + <button |
| 85 | + type="button" |
| 86 | + onClick={() => openFullView('review')} |
| 87 | + className="min-w-[7rem] flex-1 cursor-pointer text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-ring" |
| 88 | + > |
| 89 | + <span className="block truncate text-[13px] font-medium leading-[18px] tracking-[-0.25px] text-kumo-default"> |
| 90 | + {action.description.title} |
| 91 | + </span> |
| 92 | + <span className="mt-0.5 block truncate text-[11.5px] leading-4 tracking-[-0.1px] text-kumo-inactive"> |
| 93 | + {action.resourceTitle} |
| 94 | + <span className="px-1">·</span> |
| 95 | + {formatRelativeTime(action.createdAt)} |
| 96 | + </span> |
| 97 | + <span className="mt-1.5 block line-clamp-2 text-[12.5px] leading-[18px] tracking-[-0.2px] text-kumo-subtle"> |
| 98 | + {action.description.description} |
| 99 | + </span> |
| 100 | + </button> |
| 101 | + <div className="ml-auto flex flex-shrink-0 items-center gap-0.5"> |
| 102 | + <ResolveButton |
| 103 | + tone="deny" |
| 104 | + disabled={isProcessing} |
| 105 | + onClick={() => void resolveAction(action.id, 'deny')} |
| 106 | + /> |
| 107 | + <ResolveButton |
| 108 | + tone="approve" |
| 109 | + disabled={isProcessing} |
| 110 | + onClick={() => void resolveAction(action.id, 'approve')} |
| 111 | + /> |
| 112 | + </div> |
| 113 | + </div> |
| 114 | + </div> |
| 115 | + ) |
| 116 | + })} |
| 117 | + </div> |
| 118 | + )} |
| 119 | + |
| 120 | + <div className="border-t border-kumo-line p-1"> |
| 121 | + <button |
| 122 | + type="button" |
| 123 | + onClick={() => openFullView(pending.length > 0 ? 'review' : 'history')} |
| 124 | + className="flex w-full cursor-pointer items-center justify-between rounded-md px-2.5 py-1.5 text-left text-[13px] leading-[18px] tracking-[-0.25px] text-kumo-default transition-colors hover:bg-kumo-tint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-kumo-ring" |
| 125 | + > |
| 126 | + <span> |
| 127 | + {pending.length > PREVIEW_LIMIT |
| 128 | + ? `View all ${pending.length} requests` |
| 129 | + : 'View all activity'} |
| 130 | + </span> |
| 131 | + <ArrowRight size={13} className="text-kumo-inactive" /> |
| 132 | + </button> |
| 133 | + </div> |
| 134 | + </Popover.Content> |
| 135 | + </Popover> |
| 136 | + ) |
| 137 | +} |
0 commit comments