diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d4eaa..fc17457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to Pulse (frontend and product) are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**. +## [Unreleased] + +## [0.6.0-alpha] - 2026-02-13 + +### Added + +- **In-app notification center.** Bell icon in the header with dropdown of recent notifications. Uptime monitor status changes (down, degraded, recovered) create in-app notifications with links to the uptime page. + ## [0.5.1-alpha] - 2026-02-12 ### Changed @@ -51,7 +59,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), --- -[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...HEAD +[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.6.0-alpha...HEAD +[0.6.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...v0.6.0-alpha [0.5.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...v0.5.1-alpha [0.5.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.4.0-alpha...v0.5.0-alpha [0.4.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...v0.4.0-alpha diff --git a/app/layout-content.tsx b/app/layout-content.tsx index ef1e128..9dffd9d 100644 --- a/app/layout-content.tsx +++ b/app/layout-content.tsx @@ -2,7 +2,8 @@ import { OfflineBanner } from '@/components/OfflineBanner' import { Footer } from '@/components/Footer' -import { Header, GridIcon } from '@ciphera-net/ui' +import { Header } from '@ciphera-net/ui' +import NotificationCenter from '@/components/notifications/NotificationCenter' import { useAuth } from '@/lib/auth/context' import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus' import Link from 'next/link' @@ -63,6 +64,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode showSecurity={false} showPricing={true} topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined} + rightSideActions={auth.user ? : null} customNavItems={ <> {!auth.user && ( diff --git a/components/notifications/NotificationCenter.tsx b/components/notifications/NotificationCenter.tsx new file mode 100644 index 0000000..9542723 --- /dev/null +++ b/components/notifications/NotificationCenter.tsx @@ -0,0 +1,230 @@ +'use client' + +/** + * @file Notification center: bell icon with dropdown of recent notifications. + */ + +import { useEffect, useState, useRef } from 'react' +import Link from 'next/link' +import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications' +import { getAuthErrorMessage } from '@/lib/utils/authErrors' +import { AlertTriangleIcon, CheckCircleIcon } from '@ciphera-net/ui' + +// * Bell icon (simple SVG, no extra deps) +function BellIcon({ className }: { className?: string }) { + return ( + + + + + ) +} + +function formatTimeAgo(dateStr: string): string { + const d = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays < 7) return `${diffDays}d ago` + return d.toLocaleDateString() +} + +function getTypeIcon(type: string) { + if (type.includes('down') || type.includes('degraded')) { + return + } + return +} + +export default function NotificationCenter() { + const [open, setOpen] = useState(false) + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const dropdownRef = useRef(null) + + const fetchNotifications = async () => { + setLoading(true) + setError(null) + try { + const res = await listNotifications() + setNotifications(res.notifications) + setUnreadCount(res.unread_count) + } catch (err) { + setError(getAuthErrorMessage(err as Error) || 'Failed to load notifications') + setNotifications([]) + setUnreadCount(0) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (open) { + fetchNotifications() + } + }, [open]) + + // * Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + if (open) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [open]) + + const handleMarkRead = async (id: string) => { + try { + await markNotificationRead(id) + setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n))) + setUnreadCount((c) => Math.max(0, c - 1)) + } catch { + // Ignore; user can retry + } + } + + const handleMarkAllRead = async () => { + try { + await markAllNotificationsRead() + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) + setUnreadCount(0) + } catch { + // Ignore + } + } + + const handleNotificationClick = (n: Notification) => { + if (!n.read) { + handleMarkRead(n.id) + } + setOpen(false) + } + + return ( +
+ + + {open && ( +
+
+

Notifications

+ {unreadCount > 0 && ( + + )} +
+ +
+ {loading && ( +
+ Loading… +
+ )} + {error && ( +
{error}
+ )} + {!loading && !error && notifications.length === 0 && ( +
+ No notifications yet +
+ )} + {!loading && !error && notifications.length > 0 && ( +
    + {notifications.map((n) => ( +
  • + {n.link_url ? ( + handleNotificationClick(n)} + className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`} + > +
    + {getTypeIcon(n.type)} +
    +

    + {n.title} +

    + {n.body && ( +

    + {n.body} +

    + )} +

    + {formatTimeAgo(n.created_at)} +

    +
    +
    + + ) : ( +
    handleNotificationClick(n)} + onKeyDown={(e) => e.key === 'Enter' && handleNotificationClick(n)} + className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`} + > +
    + {getTypeIcon(n.type)} +
    +

    + {n.title} +

    + {n.body && ( +

    + {n.body} +

    + )} +

    + {formatTimeAgo(n.created_at)} +

    +
    +
    +
    + )} +
  • + ))} +
+ )} +
+
+ )} +
+ ) +} diff --git a/lib/api/notifications.ts b/lib/api/notifications.ts new file mode 100644 index 0000000..89c8b2c --- /dev/null +++ b/lib/api/notifications.ts @@ -0,0 +1,39 @@ +/** + * @file Notifications API client + */ + +import apiRequest from './client' + +export interface Notification { + id: string + organization_id: string + type: string + title: string + body?: string + read: boolean + link_url?: string + link_label?: string + metadata?: Record + created_at: string +} + +export interface ListNotificationsResponse { + notifications: Notification[] + unread_count: number +} + +export async function listNotifications(): Promise { + return apiRequest('/notifications') +} + +export async function markNotificationRead(id: string): Promise { + return apiRequest(`/notifications/${id}/read`, { + method: 'PATCH', + }) +} + +export async function markAllNotificationsRead(): Promise { + return apiRequest('/notifications/mark-all-read', { + method: 'POST', + }) +} diff --git a/package-lock.json b/package-lock.json index fd406ae..70663fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "pulse-frontend", - "version": "0.4.0-alpha", + "version": "0.6.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-frontend", - "version": "0.4.0-alpha", + "version": "0.6.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.49", + "@ciphera-net/ui": "^0.0.50", "@ducanh2912/next-pwa": "^10.2.9", + "@radix-ui/react-icons": "^1.3.0", "axios": "^1.13.2", "country-flag-icons": "^1.6.4", "d3-scale": "^4.0.2", @@ -44,6 +45,30 @@ "typescript": "^5.5.4" } }, + "../../ciphera-ui": { + "name": "@ciphera-net/ui", + "version": "0.0.50", + "dependencies": { + "@radix-ui/react-icons": "^1.3.0", + "clsx": "^2.1.0", + "framer-motion": "^12.0.0", + "sonner": "^2.0.7", + "tailwind-merge": "^2.2.0" + }, + "devDependencies": { + "@svgr/cli": "^8.1.0", + "@types/node": "^25.0.10", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "tailwindcss": "^3.4.0", + "tsup": "^8.0.0", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "dev": true, @@ -1469,20 +1494,8 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.49", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.49/ef6f7f06a134bc3d3b4cb1086f689ddb34f1652a", - "integrity": "sha512-ga2n0kO7JeOFzVVRX+FU5iQxodv2yE/hUnlEUHEomorKzWCADM9wAOLGcxi8mcVz49jy/4IQlHRdpF9LH64uQg==", - "dependencies": { - "@radix-ui/react-icons": "^1.3.0", - "clsx": "^2.1.0", - "framer-motion": "^12.0.0", - "sonner": "^2.0.7", - "tailwind-merge": "^2.2.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } + "resolved": "../../ciphera-ui", + "link": true }, "node_modules/@ducanh2912/next-pwa": { "version": "10.2.9", @@ -2436,6 +2449,8 @@ }, "node_modules/@radix-ui/react-icons": { "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", "license": "MIT", "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" @@ -9411,14 +9426,6 @@ "node": ">=12.0.0" } }, - "node_modules/tailwind-merge": { - "version": "2.6.0", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, "node_modules/tailwindcss": { "version": "3.4.19", "dev": true, diff --git a/package.json b/package.json index 755265e..2b16e4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-frontend", - "version": "0.5.0-alpha", + "version": "0.6.0-alpha", "private": true, "scripts": { "dev": "next dev", @@ -10,7 +10,8 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.49", + "@ciphera-net/ui": "^0.0.50", + "@radix-ui/react-icons": "^1.3.0", "@ducanh2912/next-pwa": "^10.2.9", "axios": "^1.13.2", "country-flag-icons": "^1.6.4",