diff options
Diffstat (limited to 'apps/web/app/reader/_components/notification-panel.tsx')
| -rw-r--r-- | apps/web/app/reader/_components/notification-panel.tsx | 129 |
1 files changed, 129 insertions, 0 deletions
diff --git a/apps/web/app/reader/_components/notification-panel.tsx b/apps/web/app/reader/_components/notification-panel.tsx new file mode 100644 index 0000000..216741f --- /dev/null +++ b/apps/web/app/reader/_components/notification-panel.tsx @@ -0,0 +1,129 @@ +"use client" + +import { useEffect, useRef } from "react" +import { formatDistanceToNow } from "date-fns" +import { toast } from "sonner" +import { + useNotificationStore, + type StoredNotification, +} from "@/lib/stores/notification-store" + +export function NotificationPanel({ onClose }: { onClose: () => void }) { + const panelReference = useRef<HTMLDivElement>(null) + const notifications = useNotificationStore((state) => state.notifications) + const dismissNotification = useNotificationStore( + (state) => state.dismissNotification + ) + const clearAllNotifications = useNotificationStore( + (state) => state.clearAllNotifications + ) + const markAllAsViewed = useNotificationStore( + (state) => state.markAllAsViewed + ) + + useEffect(() => { + markAllAsViewed() + }, [markAllAsViewed]) + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + panelReference.current && + !panelReference.current.contains(event.target as Node) + ) { + onClose() + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [onClose]) + + function handleNotificationClick(notification: StoredNotification) { + if (notification.actionUrl) { + navigator.clipboard.writeText(notification.actionUrl) + toast("link copied to clipboard") + } + } + + return ( + <div + ref={panelReference} + className="fixed bottom-16 left-2 z-50 w-80 max-w-[calc(100vw-1rem)] border border-border bg-background-secondary shadow-lg md:absolute md:bottom-full md:left-0 md:mb-1" + > + <div className="flex items-center justify-between border-b border-border px-3 py-2"> + <span className="text-text-primary">notifications</span> + {notifications.length > 0 && ( + <button + type="button" + onClick={() => { + clearAllNotifications() + onClose() + }} + className="text-text-dim transition-colors hover:text-text-secondary" + > + clear all + </button> + )} + </div> + <div className="max-h-64 overflow-auto"> + {notifications.length === 0 ? ( + <p className="px-3 py-4 text-center text-text-dim"> + no notifications + </p> + ) : ( + notifications.map((notification: StoredNotification) => ( + <div + key={notification.identifier} + className={`flex items-start gap-2 border-b border-border px-3 py-2 last:border-b-0 ${ + notification.actionUrl + ? "cursor-pointer transition-colors hover:bg-background-tertiary" + : "" + }`} + onClick={ + notification.actionUrl + ? () => handleNotificationClick(notification) + : undefined + } + > + <div className="min-w-0 flex-1"> + <p className="text-text-secondary">{notification.message}</p> + {notification.actionUrl && ( + <p className="mt-0.5 text-text-dim"> + tap to copy link + </p> + )} + <p className="mt-0.5 text-text-dim"> + {formatDistanceToNow(new Date(notification.timestamp), { + addSuffix: true, + })} + </p> + </div> + <button + type="button" + onClick={(event) => { + event.stopPropagation() + dismissNotification(notification.identifier) + }} + className="shrink-0 px-1 text-text-dim transition-colors hover:text-text-secondary" + > + × + </button> + </div> + )) + )} + </div> + </div> + ) +} + +export function useUnviewedNotificationCount(): number { + const notifications = useNotificationStore((state) => state.notifications) + const lastViewedAt = useNotificationStore((state) => state.lastViewedAt) + + if (!lastViewedAt) return notifications.length + + return notifications.filter( + (notification) => notification.timestamp > lastViewedAt + ).length +} |