@wakastellar/ui 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/add.d.ts +7 -0
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/list.d.ts +5 -0
- package/dist/cli/commands/search.d.ts +1 -0
- package/dist/cli/index.cjs +4844 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/utils/config.d.ts +29 -0
- package/dist/cli/utils/logger.d.ts +20 -0
- package/dist/cli/utils/registry.d.ts +23 -0
- package/package.json +14 -3
- package/src/blocks/activity-timeline/index.tsx +586 -0
- package/src/blocks/calendar-view/index.tsx +756 -0
- package/src/blocks/chat/index.tsx +1018 -0
- package/src/blocks/chat/widget.tsx +504 -0
- package/src/blocks/dashboard/index.tsx +522 -0
- package/src/blocks/empty-states/index.tsx +452 -0
- package/src/blocks/error-pages/index.tsx +426 -0
- package/src/blocks/faq/index.tsx +479 -0
- package/src/blocks/file-manager/index.tsx +890 -0
- package/src/blocks/footer/index.tsx +133 -0
- package/src/blocks/header/index.tsx +357 -0
- package/src/blocks/headtab/index.tsx +139 -0
- package/src/blocks/i18n-editor/index.tsx +1016 -0
- package/src/blocks/index.ts +80 -0
- package/src/blocks/kanban-board/index.tsx +779 -0
- package/src/blocks/landing/index.tsx +677 -0
- package/src/blocks/language-selector/index.tsx +88 -0
- package/src/blocks/layout/index.tsx +159 -0
- package/src/blocks/login/index.tsx +339 -0
- package/src/blocks/login/types.ts +131 -0
- package/src/blocks/pricing/index.tsx +564 -0
- package/src/blocks/profile/index.tsx +746 -0
- package/src/blocks/settings/index.tsx +558 -0
- package/src/blocks/sidebar/index.tsx +713 -0
- package/src/blocks/theme-creator-block/index.tsx +835 -0
- package/src/blocks/user-management/index.tsx +1037 -0
- package/src/blocks/wizard/index.tsx +719 -0
- package/src/components/DataTable/DataTable.tsx +406 -0
- package/src/components/DataTable/DataTableAdvanced.tsx +720 -0
- package/src/components/DataTable/DataTableBody.tsx +216 -0
- package/src/components/DataTable/DataTableCell.tsx +172 -0
- package/src/components/DataTable/DataTableColumnResizer.tsx +62 -0
- package/src/components/DataTable/DataTableConflictResolver.tsx +478 -0
- package/src/components/DataTable/DataTableContextMenu.tsx +219 -0
- package/src/components/DataTable/DataTableEditCell.tsx +279 -0
- package/src/components/DataTable/DataTableFilterBuilder.tsx +519 -0
- package/src/components/DataTable/DataTableFilters.tsx +535 -0
- package/src/components/DataTable/DataTableGrouping.tsx +147 -0
- package/src/components/DataTable/DataTableHeader.tsx +172 -0
- package/src/components/DataTable/DataTablePagination.tsx +125 -0
- package/src/components/DataTable/DataTableSelection.tsx +269 -0
- package/src/components/DataTable/DataTableSyncStatus.tsx +281 -0
- package/src/components/DataTable/DataTableToolbar.tsx +262 -0
- package/src/components/DataTable/README.md +446 -0
- package/src/components/DataTable/__tests__/DataTableAdvanced.test.tsx +426 -0
- package/src/components/DataTable/__tests__/DataTableEdit.test.tsx +329 -0
- package/src/components/DataTable/__tests__/useDataTableAdvanced.test.ts +455 -0
- package/src/components/DataTable/examples/EditExample.tsx +166 -0
- package/src/components/DataTable/formatters/index.ts +335 -0
- package/src/components/DataTable/hooks/__tests__/useDataTableEdit.test.ts +239 -0
- package/src/components/DataTable/hooks/useDataTable.ts +145 -0
- package/src/components/DataTable/hooks/useDataTableAdvanced.ts +342 -0
- package/src/components/DataTable/hooks/useDataTableAdvancedFilters.ts +637 -0
- package/src/components/DataTable/hooks/useDataTableColumnTemplates.ts +186 -0
- package/src/components/DataTable/hooks/useDataTableEdit.ts +167 -0
- package/src/components/DataTable/hooks/useDataTableExport.ts +227 -0
- package/src/components/DataTable/hooks/useDataTableImport.ts +216 -0
- package/src/components/DataTable/hooks/useDataTableOffline.ts +481 -0
- package/src/components/DataTable/hooks/useDataTableTheme.ts +213 -0
- package/src/components/DataTable/hooks/useDataTableVirtualization.ts +99 -0
- package/src/components/DataTable/hooks/useTableLayout.ts +85 -0
- package/src/components/DataTable/index.ts +81 -0
- package/src/components/DataTable/services/IndexedDBService.ts +504 -0
- package/src/components/DataTable/templates/index.tsx +803 -0
- package/src/components/DataTable/types.ts +504 -0
- package/src/components/DataTable/utils.ts +164 -0
- package/src/components/DataTable/workers/exportWorker.ts +213 -0
- package/src/components/accordion/index.tsx +61 -0
- package/src/components/alert/index.tsx +61 -0
- package/src/components/alert-dialog/index.tsx +146 -0
- package/src/components/aspect-ratio/index.tsx +12 -0
- package/src/components/avatar/index.tsx +54 -0
- package/src/components/badge/Badge.stories.tsx +64 -0
- package/src/components/badge/index.tsx +38 -0
- package/src/components/button/Button.stories.tsx +173 -0
- package/src/components/button/index.tsx +56 -0
- package/src/components/calendar/index.tsx +73 -0
- package/src/components/card/index.tsx +78 -0
- package/src/components/checkbox/index.tsx +34 -0
- package/src/components/code/index.tsx +229 -0
- package/src/components/collapsible/index.tsx +16 -0
- package/src/components/command/index.tsx +162 -0
- package/src/components/context-menu/index.tsx +204 -0
- package/src/components/dialog/index.tsx +126 -0
- package/src/components/dropdown-menu/index.tsx +204 -0
- package/src/components/error-boundary/ErrorBoundary.tsx +281 -0
- package/src/components/error-boundary/index.ts +7 -0
- package/src/components/form/index.tsx +183 -0
- package/src/components/hover-card/index.tsx +33 -0
- package/src/components/index.ts +368 -0
- package/src/components/input/Input.stories.tsx +100 -0
- package/src/components/input/index.tsx +27 -0
- package/src/components/input-otp/index.tsx +277 -0
- package/src/components/label/index.tsx +30 -0
- package/src/components/language-selector/index.tsx +341 -0
- package/src/components/menubar/index.tsx +240 -0
- package/src/components/navigation-menu/index.tsx +134 -0
- package/src/components/popover/index.tsx +35 -0
- package/src/components/progress/index.tsx +32 -0
- package/src/components/radio-group/index.tsx +48 -0
- package/src/components/scroll-area/index.tsx +52 -0
- package/src/components/select/index.tsx +164 -0
- package/src/components/separator/index.tsx +35 -0
- package/src/components/sheet/index.tsx +147 -0
- package/src/components/skeleton/index.tsx +22 -0
- package/src/components/slider/index.tsx +32 -0
- package/src/components/switch/index.tsx +33 -0
- package/src/components/table/index.tsx +117 -0
- package/src/components/tabs/index.tsx +59 -0
- package/src/components/textarea/index.tsx +30 -0
- package/src/components/theme-selector/index.tsx +327 -0
- package/src/components/toast/index.tsx +133 -0
- package/src/components/toaster/index.tsx +34 -0
- package/src/components/toggle/index.tsx +49 -0
- package/src/components/tooltip/index.tsx +34 -0
- package/src/components/typography/index.tsx +276 -0
- package/src/components/waka-3d-pie-chart/index.tsx +486 -0
- package/src/components/waka-achievement-unlock/index.tsx +716 -0
- package/src/components/waka-activity-feed/index.tsx +686 -0
- package/src/components/waka-address-autocomplete/index.tsx +1202 -0
- package/src/components/waka-admincrumb/index.tsx +349 -0
- package/src/components/waka-alert-stack/index.tsx +827 -0
- package/src/components/waka-allocation-matrix/index.tsx +1278 -0
- package/src/components/waka-approval-chain/index.tsx +766 -0
- package/src/components/waka-audit-log/index.tsx +1475 -0
- package/src/components/waka-autocomplete/index.tsx +358 -0
- package/src/components/waka-badge-showcase/index.tsx +704 -0
- package/src/components/waka-barcode/index.tsx +260 -0
- package/src/components/waka-biometric-prompt/index.tsx +765 -0
- package/src/components/waka-bottom-sheet/index.tsx +495 -0
- package/src/components/waka-breadcrumb/index.tsx +376 -0
- package/src/components/waka-breadcrumb-path/index.tsx +513 -0
- package/src/components/waka-budget-burn/index.tsx +1234 -0
- package/src/components/waka-capacity-planner/index.tsx +1107 -0
- package/src/components/waka-carousel/index.tsx +893 -0
- package/src/components/waka-cart-summary/index.tsx +1055 -0
- package/src/components/waka-challenge-timer/index.tsx +1044 -0
- package/src/components/waka-charts/WakaAreaChart.tsx +251 -0
- package/src/components/waka-charts/WakaBarChart.tsx +222 -0
- package/src/components/waka-charts/WakaChart.tsx +124 -0
- package/src/components/waka-charts/WakaLineChart.tsx +219 -0
- package/src/components/waka-charts/WakaMiniChart.tsx +133 -0
- package/src/components/waka-charts/WakaPieChart.tsx +214 -0
- package/src/components/waka-charts/WakaSparkline.tsx +229 -0
- package/src/components/waka-charts/dataTableHelpers.ts +109 -0
- package/src/components/waka-charts/hooks/useChartTheme.ts +123 -0
- package/src/components/waka-charts/hooks/useRechartsLoader.ts +234 -0
- package/src/components/waka-charts/index.ts +90 -0
- package/src/components/waka-charts/types.ts +330 -0
- package/src/components/waka-chat-bubble/index.tsx +1060 -0
- package/src/components/waka-checklist/index.tsx +1067 -0
- package/src/components/waka-checkout-stepper/index.tsx +976 -0
- package/src/components/waka-cohort-table/index.tsx +1011 -0
- package/src/components/waka-color-picker/index.tsx +447 -0
- package/src/components/waka-combo-counter/index.tsx +864 -0
- package/src/components/waka-combobox/index.tsx +497 -0
- package/src/components/waka-command-bar/index.tsx +403 -0
- package/src/components/waka-compare-period/index.tsx +1230 -0
- package/src/components/waka-connection-matrix/index.tsx +1053 -0
- package/src/components/waka-contribution-graph/index.tsx +552 -0
- package/src/components/waka-cost-breakdown/index.tsx +1065 -0
- package/src/components/waka-coupon-input/index.tsx +592 -0
- package/src/components/waka-credit-card-input/index.tsx +982 -0
- package/src/components/waka-daily-reward/index.tsx +762 -0
- package/src/components/waka-date-range-picker/index.tsx +378 -0
- package/src/components/waka-datetime-picker/index.tsx +793 -0
- package/src/components/waka-datetime-picker.form-integration/index.tsx +402 -0
- package/src/components/waka-deployment-lane/index.tsx +673 -0
- package/src/components/waka-device-trust/index.tsx +1259 -0
- package/src/components/waka-dock/index.tsx +285 -0
- package/src/components/waka-drawer/index.tsx +319 -0
- package/src/components/waka-empty-state/index.tsx +545 -0
- package/src/components/waka-error-shake/index.tsx +398 -0
- package/src/components/waka-feature-announcement/index.tsx +991 -0
- package/src/components/waka-file-upload/index.tsx +437 -0
- package/src/components/waka-floating-nav/index.tsx +413 -0
- package/src/components/waka-flow-diagram/index.tsx +508 -0
- package/src/components/waka-funnel-chart/index.tsx +823 -0
- package/src/components/waka-glow-card/index.tsx +246 -0
- package/src/components/waka-goal-progress/index.tsx +1025 -0
- package/src/components/waka-haptic-button/index.tsx +388 -0
- package/src/components/waka-health-pulse/index.tsx +451 -0
- package/src/components/waka-heatmap/index.tsx +1026 -0
- package/src/components/waka-hotspot/index.tsx +682 -0
- package/src/components/waka-image/index.tsx +373 -0
- package/src/components/waka-incident-timeline/index.tsx +686 -0
- package/src/components/waka-invoice-preview/index.tsx +829 -0
- package/src/components/waka-kanban/index.tsx +646 -0
- package/src/components/waka-kpi-dashboard/index.tsx +755 -0
- package/src/components/waka-leaderboard/index.tsx +746 -0
- package/src/components/waka-level-progress/index.tsx +665 -0
- package/src/components/waka-liquid-button/index.tsx +520 -0
- package/src/components/waka-loading-orbit/index.tsx +478 -0
- package/src/components/waka-loot-box/index.tsx +1091 -0
- package/src/components/waka-magic-link/index.tsx +321 -0
- package/src/components/waka-magnetic-button/index.tsx +567 -0
- package/src/components/waka-mention-input/index.tsx +953 -0
- package/src/components/waka-metric-sparkline/index.tsx +627 -0
- package/src/components/waka-milestone-road/index.tsx +1064 -0
- package/src/components/waka-modal/index.tsx +374 -0
- package/src/components/waka-morph-button/index.tsx +495 -0
- package/src/components/waka-network-topology/index.tsx +801 -0
- package/src/components/waka-notifications/index.tsx +414 -0
- package/src/components/waka-number-input/index.tsx +373 -0
- package/src/components/waka-orbital-menu/index.tsx +445 -0
- package/src/components/waka-order-tracker/index.tsx +1041 -0
- package/src/components/waka-pagination/index.tsx +393 -0
- package/src/components/waka-password-strength/index.tsx +824 -0
- package/src/components/waka-payment-method-picker/index.tsx +715 -0
- package/src/components/waka-permission-matrix/index.tsx +1302 -0
- package/src/components/waka-phone-input/index.tsx +801 -0
- package/src/components/waka-pipeline-view/index.tsx +604 -0
- package/src/components/waka-player-card/index.tsx +691 -0
- package/src/components/waka-points-popup/index.tsx +366 -0
- package/src/components/waka-power-up/index.tsx +1155 -0
- package/src/components/waka-presence-indicator/index.tsx +1181 -0
- package/src/components/waka-pricing-table/index.tsx +755 -0
- package/src/components/waka-product-card/index.tsx +786 -0
- package/src/components/waka-progress-onboarding/index.tsx +878 -0
- package/src/components/waka-pull-to-refresh/index.tsx +451 -0
- package/src/components/waka-qrcode/index.tsx +232 -0
- package/src/components/waka-quest-card/index.tsx +1275 -0
- package/src/components/waka-quota-bar/index.tsx +693 -0
- package/src/components/waka-radar-score/index.tsx +512 -0
- package/src/components/waka-rank-badge/index.tsx +813 -0
- package/src/components/waka-rating-input/index.tsx +560 -0
- package/src/components/waka-reaction-picker/index.tsx +1062 -0
- package/src/components/waka-region-map/index.tsx +730 -0
- package/src/components/waka-resource-gauge/index.tsx +654 -0
- package/src/components/waka-resource-pool/index.tsx +1035 -0
- package/src/components/waka-rich-text-editor/index.tsx +594 -0
- package/src/components/waka-rollback-slider/index.tsx +891 -0
- package/src/components/waka-sankey-diagram/index.tsx +1032 -0
- package/src/components/waka-schedule-picker/index.tsx +1060 -0
- package/src/components/waka-scratch-card/index.tsx +914 -0
- package/src/components/waka-season-pass/index.tsx +886 -0
- package/src/components/waka-security-score/index.tsx +1126 -0
- package/src/components/waka-segmented-control/index.tsx +238 -0
- package/src/components/waka-server-rack/index.tsx +764 -0
- package/src/components/waka-session-manager/index.tsx +815 -0
- package/src/components/waka-signature-pad/index.tsx +744 -0
- package/src/components/waka-skeleton-wave/index.tsx +454 -0
- package/src/components/waka-skill-tree/index.tsx +1031 -0
- package/src/components/waka-sla-tracker/index.tsx +798 -0
- package/src/components/waka-slider-range/index.tsx +765 -0
- package/src/components/waka-spin-wheel/index.tsx +671 -0
- package/src/components/waka-spinner/index.tsx +284 -0
- package/src/components/waka-spotlight/index.tsx +410 -0
- package/src/components/waka-stat/index.tsx +428 -0
- package/src/components/waka-stats-hexagon/index.tsx +824 -0
- package/src/components/waka-status-matrix/index.tsx +565 -0
- package/src/components/waka-stepper/index.tsx +489 -0
- package/src/components/waka-streak-counter/index.tsx +334 -0
- package/src/components/waka-success-explosion/index.tsx +453 -0
- package/src/components/waka-swipe-card/index.tsx +574 -0
- package/src/components/waka-tabs-morph/index.tsx +509 -0
- package/src/components/waka-tag-input/index.tsx +877 -0
- package/src/components/waka-team-banner/index.tsx +1183 -0
- package/src/components/waka-terminal-output/index.tsx +836 -0
- package/src/components/waka-theme-creator/index.tsx +762 -0
- package/src/components/waka-theme-manager/index.tsx +654 -0
- package/src/components/waka-thread-view/index.tsx +874 -0
- package/src/components/waka-tilt-card/index.tsx +250 -0
- package/src/components/waka-time-picker/index.tsx +479 -0
- package/src/components/waka-timeline/index.tsx +385 -0
- package/src/components/waka-tooltip-tour/index.tsx +855 -0
- package/src/components/waka-tour-guide/index.tsx +920 -0
- package/src/components/waka-tournament-bracket/index.tsx +1276 -0
- package/src/components/waka-tree/index.tsx +557 -0
- package/src/components/waka-treemap-chart/index.tsx +1031 -0
- package/src/components/waka-two-factor-setup/index.tsx +995 -0
- package/src/components/waka-typewriter/index.tsx +566 -0
- package/src/components/waka-typing-indicator/index.tsx +649 -0
- package/src/components/waka-versus-card/index.tsx +1026 -0
- package/src/components/waka-video/index.tsx +557 -0
- package/src/components/waka-video-call/index.tsx +1087 -0
- package/src/components/waka-virtual-list/index.tsx +327 -0
- package/src/components/waka-voice-message/index.tsx +1019 -0
- package/src/components/waka-welcome-modal/index.tsx +790 -0
- package/src/components/waka-xp-bar/index.tsx +799 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "../../utils/cn"
|
|
5
|
+
import {
|
|
6
|
+
AlertTriangle,
|
|
7
|
+
AlertCircle,
|
|
8
|
+
Info,
|
|
9
|
+
Check,
|
|
10
|
+
Clock,
|
|
11
|
+
ChevronDown,
|
|
12
|
+
ChevronUp,
|
|
13
|
+
X,
|
|
14
|
+
Bell,
|
|
15
|
+
BellOff,
|
|
16
|
+
Volume2,
|
|
17
|
+
} from "lucide-react"
|
|
18
|
+
|
|
19
|
+
// ============================================
|
|
20
|
+
// TYPES
|
|
21
|
+
// ============================================
|
|
22
|
+
|
|
23
|
+
export type AlertSeverity = "critical" | "warning" | "info"
|
|
24
|
+
|
|
25
|
+
export interface Alert {
|
|
26
|
+
id: string
|
|
27
|
+
title: string
|
|
28
|
+
description?: string
|
|
29
|
+
severity: AlertSeverity
|
|
30
|
+
source?: string
|
|
31
|
+
timestamp: Date
|
|
32
|
+
acknowledged?: boolean
|
|
33
|
+
acknowledgedBy?: string
|
|
34
|
+
snoozedUntil?: Date
|
|
35
|
+
count?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WakaAlertStackProps {
|
|
39
|
+
/** Array of alerts to display */
|
|
40
|
+
alerts: Alert[]
|
|
41
|
+
/** Callback when an alert is acknowledged */
|
|
42
|
+
onAcknowledge?: (alertId: string) => void
|
|
43
|
+
/** Callback when an alert is snoozed */
|
|
44
|
+
onSnooze?: (alertId: string, duration: number) => void
|
|
45
|
+
/** Callback when an alert is dismissed */
|
|
46
|
+
onDismiss?: (alertId: string) => void
|
|
47
|
+
/** Maximum number of visible alerts in the stack */
|
|
48
|
+
maxVisible?: number
|
|
49
|
+
/** Whether to group similar alerts */
|
|
50
|
+
groupSimilar?: boolean
|
|
51
|
+
/** Whether to animate entry/exit */
|
|
52
|
+
animated?: boolean
|
|
53
|
+
/** Additional CSS classes */
|
|
54
|
+
className?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================
|
|
58
|
+
// SEVERITY CONFIG
|
|
59
|
+
// ============================================
|
|
60
|
+
|
|
61
|
+
const severityConfig: Record<
|
|
62
|
+
AlertSeverity,
|
|
63
|
+
{
|
|
64
|
+
icon: React.ElementType
|
|
65
|
+
color: string
|
|
66
|
+
bgColor: string
|
|
67
|
+
borderColor: string
|
|
68
|
+
ringColor: string
|
|
69
|
+
label: string
|
|
70
|
+
priority: number
|
|
71
|
+
}
|
|
72
|
+
> = {
|
|
73
|
+
critical: {
|
|
74
|
+
icon: AlertTriangle,
|
|
75
|
+
color: "text-red-600 dark:text-red-300",
|
|
76
|
+
bgColor: "bg-red-50 dark:bg-red-900/40",
|
|
77
|
+
borderColor: "border-red-500 dark:border-red-400",
|
|
78
|
+
ringColor: "ring-red-500/20 dark:ring-red-400/30",
|
|
79
|
+
label: "Critical",
|
|
80
|
+
priority: 3,
|
|
81
|
+
},
|
|
82
|
+
warning: {
|
|
83
|
+
icon: AlertCircle,
|
|
84
|
+
color: "text-amber-600 dark:text-amber-300",
|
|
85
|
+
bgColor: "bg-amber-50 dark:bg-amber-900/40",
|
|
86
|
+
borderColor: "border-amber-500 dark:border-amber-400",
|
|
87
|
+
ringColor: "ring-amber-500/20 dark:ring-amber-400/30",
|
|
88
|
+
label: "Warning",
|
|
89
|
+
priority: 2,
|
|
90
|
+
},
|
|
91
|
+
info: {
|
|
92
|
+
icon: Info,
|
|
93
|
+
color: "text-blue-600 dark:text-blue-300",
|
|
94
|
+
bgColor: "bg-blue-50 dark:bg-blue-900/40",
|
|
95
|
+
borderColor: "border-blue-500 dark:border-blue-400",
|
|
96
|
+
ringColor: "ring-blue-500/20 dark:ring-blue-400/30",
|
|
97
|
+
label: "Info",
|
|
98
|
+
priority: 1,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============================================
|
|
103
|
+
// SNOOZE DURATIONS
|
|
104
|
+
// ============================================
|
|
105
|
+
|
|
106
|
+
const snoozeDurations = [
|
|
107
|
+
{ label: "5 min", value: 5 * 60 * 1000 },
|
|
108
|
+
{ label: "15 min", value: 15 * 60 * 1000 },
|
|
109
|
+
{ label: "30 min", value: 30 * 60 * 1000 },
|
|
110
|
+
{ label: "1 hour", value: 60 * 60 * 1000 },
|
|
111
|
+
{ label: "4 hours", value: 4 * 60 * 60 * 1000 },
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
// ============================================
|
|
115
|
+
// UTILITY FUNCTIONS
|
|
116
|
+
// ============================================
|
|
117
|
+
|
|
118
|
+
function formatRelativeTime(date: Date): string {
|
|
119
|
+
const now = new Date()
|
|
120
|
+
const diffMs = now.getTime() - date.getTime()
|
|
121
|
+
const diffMins = Math.floor(diffMs / 60000)
|
|
122
|
+
const diffHours = Math.floor(diffMs / 3600000)
|
|
123
|
+
const diffDays = Math.floor(diffMs / 86400000)
|
|
124
|
+
|
|
125
|
+
if (diffMins < 1) return "Just now"
|
|
126
|
+
if (diffMins < 60) return `${diffMins}m ago`
|
|
127
|
+
if (diffHours < 24) return `${diffHours}h ago`
|
|
128
|
+
if (diffDays < 7) return `${diffDays}d ago`
|
|
129
|
+
return date.toLocaleDateString()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatDuration(date: Date): string {
|
|
133
|
+
const now = new Date()
|
|
134
|
+
const diffMs = now.getTime() - date.getTime()
|
|
135
|
+
const diffMins = Math.floor(diffMs / 60000)
|
|
136
|
+
const diffHours = Math.floor(diffMs / 3600000)
|
|
137
|
+
|
|
138
|
+
if (diffMins < 60) return `${diffMins}m`
|
|
139
|
+
if (diffHours < 24) return `${diffHours}h ${diffMins % 60}m`
|
|
140
|
+
return `${Math.floor(diffHours / 24)}d ${diffHours % 24}h`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatSnoozeRemaining(snoozedUntil: Date): string {
|
|
144
|
+
const now = new Date()
|
|
145
|
+
const diffMs = snoozedUntil.getTime() - now.getTime()
|
|
146
|
+
|
|
147
|
+
if (diffMs <= 0) return "Expired"
|
|
148
|
+
|
|
149
|
+
const diffMins = Math.floor(diffMs / 60000)
|
|
150
|
+
const diffHours = Math.floor(diffMs / 3600000)
|
|
151
|
+
|
|
152
|
+
if (diffMins < 60) return `${diffMins}m remaining`
|
|
153
|
+
return `${diffHours}h ${diffMins % 60}m remaining`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ============================================
|
|
157
|
+
// HOOKS
|
|
158
|
+
// ============================================
|
|
159
|
+
|
|
160
|
+
export interface UseSoundNotificationOptions {
|
|
161
|
+
enabled?: boolean
|
|
162
|
+
volume?: number
|
|
163
|
+
soundUrl?: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function useSoundNotification(options: UseSoundNotificationOptions = {}) {
|
|
167
|
+
const { enabled = true, volume = 0.5, soundUrl } = options
|
|
168
|
+
const audioRef = React.useRef<HTMLAudioElement | null>(null)
|
|
169
|
+
const [isMuted, setIsMuted] = React.useState(!enabled)
|
|
170
|
+
|
|
171
|
+
React.useEffect(() => {
|
|
172
|
+
if (typeof window !== "undefined" && soundUrl) {
|
|
173
|
+
audioRef.current = new Audio(soundUrl)
|
|
174
|
+
audioRef.current.volume = volume
|
|
175
|
+
}
|
|
176
|
+
return () => {
|
|
177
|
+
if (audioRef.current) {
|
|
178
|
+
audioRef.current.pause()
|
|
179
|
+
audioRef.current = null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}, [soundUrl, volume])
|
|
183
|
+
|
|
184
|
+
const playSound = React.useCallback(() => {
|
|
185
|
+
if (!isMuted && audioRef.current) {
|
|
186
|
+
audioRef.current.currentTime = 0
|
|
187
|
+
audioRef.current.play().catch(() => {
|
|
188
|
+
// Autoplay may be blocked
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}, [isMuted])
|
|
192
|
+
|
|
193
|
+
const toggleMute = React.useCallback(() => {
|
|
194
|
+
setIsMuted((prev) => !prev)
|
|
195
|
+
}, [])
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
playSound,
|
|
199
|
+
isMuted,
|
|
200
|
+
toggleMute,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface UseAlertStackOptions {
|
|
205
|
+
onNewAlert?: (alert: Alert) => void
|
|
206
|
+
playSoundOnCritical?: boolean
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function useAlertStack(
|
|
210
|
+
initialAlerts: Alert[] = [],
|
|
211
|
+
options: UseAlertStackOptions = {}
|
|
212
|
+
) {
|
|
213
|
+
const [alerts, setAlerts] = React.useState<Alert[]>(initialAlerts)
|
|
214
|
+
const { onNewAlert, playSoundOnCritical = true } = options
|
|
215
|
+
const prevAlertsRef = React.useRef<Set<string>>(new Set(initialAlerts.map((a) => a.id)))
|
|
216
|
+
|
|
217
|
+
const addAlert = React.useCallback(
|
|
218
|
+
(alert: Omit<Alert, "id" | "timestamp">) => {
|
|
219
|
+
const newAlert: Alert = {
|
|
220
|
+
...alert,
|
|
221
|
+
id: `alert-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
222
|
+
timestamp: new Date(),
|
|
223
|
+
}
|
|
224
|
+
setAlerts((prev) => [newAlert, ...prev])
|
|
225
|
+
onNewAlert?.(newAlert)
|
|
226
|
+
return newAlert
|
|
227
|
+
},
|
|
228
|
+
[onNewAlert]
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
const acknowledgeAlert = React.useCallback((alertId: string, acknowledgedBy?: string) => {
|
|
232
|
+
setAlerts((prev) =>
|
|
233
|
+
prev.map((a) =>
|
|
234
|
+
a.id === alertId
|
|
235
|
+
? { ...a, acknowledged: true, acknowledgedBy }
|
|
236
|
+
: a
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
}, [])
|
|
240
|
+
|
|
241
|
+
const snoozeAlert = React.useCallback((alertId: string, duration: number) => {
|
|
242
|
+
const snoozedUntil = new Date(Date.now() + duration)
|
|
243
|
+
setAlerts((prev) =>
|
|
244
|
+
prev.map((a) =>
|
|
245
|
+
a.id === alertId ? { ...a, snoozedUntil } : a
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
}, [])
|
|
249
|
+
|
|
250
|
+
const dismissAlert = React.useCallback((alertId: string) => {
|
|
251
|
+
setAlerts((prev) => prev.filter((a) => a.id !== alertId))
|
|
252
|
+
}, [])
|
|
253
|
+
|
|
254
|
+
const clearAcknowledged = React.useCallback(() => {
|
|
255
|
+
setAlerts((prev) => prev.filter((a) => !a.acknowledged))
|
|
256
|
+
}, [])
|
|
257
|
+
|
|
258
|
+
const clearAll = React.useCallback(() => {
|
|
259
|
+
setAlerts([])
|
|
260
|
+
}, [])
|
|
261
|
+
|
|
262
|
+
// Check for new alerts
|
|
263
|
+
React.useEffect(() => {
|
|
264
|
+
const currentIds = new Set(alerts.map((a) => a.id))
|
|
265
|
+
const newAlerts = alerts.filter((a) => !prevAlertsRef.current.has(a.id))
|
|
266
|
+
|
|
267
|
+
if (playSoundOnCritical) {
|
|
268
|
+
const hasCritical = newAlerts.some((a) => a.severity === "critical")
|
|
269
|
+
if (hasCritical) {
|
|
270
|
+
// Sound notification hook should be used externally
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
prevAlertsRef.current = currentIds
|
|
275
|
+
}, [alerts, playSoundOnCritical])
|
|
276
|
+
|
|
277
|
+
const activeAlerts = React.useMemo(() => {
|
|
278
|
+
const now = new Date()
|
|
279
|
+
return alerts.filter((a) => {
|
|
280
|
+
if (a.snoozedUntil && a.snoozedUntil > now) return false
|
|
281
|
+
return true
|
|
282
|
+
})
|
|
283
|
+
}, [alerts])
|
|
284
|
+
|
|
285
|
+
const snoozedAlerts = React.useMemo(() => {
|
|
286
|
+
const now = new Date()
|
|
287
|
+
return alerts.filter((a) => a.snoozedUntil && a.snoozedUntil > now)
|
|
288
|
+
}, [alerts])
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
alerts,
|
|
292
|
+
activeAlerts,
|
|
293
|
+
snoozedAlerts,
|
|
294
|
+
addAlert,
|
|
295
|
+
acknowledgeAlert,
|
|
296
|
+
snoozeAlert,
|
|
297
|
+
dismissAlert,
|
|
298
|
+
clearAcknowledged,
|
|
299
|
+
clearAll,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ============================================
|
|
304
|
+
// SUB-COMPONENTS
|
|
305
|
+
// ============================================
|
|
306
|
+
|
|
307
|
+
interface AlertCardProps {
|
|
308
|
+
alert: Alert
|
|
309
|
+
onAcknowledge?: (alertId: string) => void
|
|
310
|
+
onSnooze?: (alertId: string, duration: number) => void
|
|
311
|
+
onDismiss?: (alertId: string) => void
|
|
312
|
+
isStacked?: boolean
|
|
313
|
+
stackIndex?: number
|
|
314
|
+
animated?: boolean
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function AlertCard({
|
|
318
|
+
alert,
|
|
319
|
+
onAcknowledge,
|
|
320
|
+
onSnooze,
|
|
321
|
+
onDismiss,
|
|
322
|
+
isStacked = false,
|
|
323
|
+
stackIndex = 0,
|
|
324
|
+
animated = true,
|
|
325
|
+
}: AlertCardProps) {
|
|
326
|
+
const [isExpanded, setIsExpanded] = React.useState(false)
|
|
327
|
+
const [showSnoozeMenu, setShowSnoozeMenu] = React.useState(false)
|
|
328
|
+
const snoozeMenuRef = React.useRef<HTMLDivElement>(null)
|
|
329
|
+
|
|
330
|
+
const config = severityConfig[alert.severity]
|
|
331
|
+
const Icon = config.icon
|
|
332
|
+
const isSnoozed = alert.snoozedUntil && alert.snoozedUntil > new Date()
|
|
333
|
+
|
|
334
|
+
// Close snooze menu when clicking outside
|
|
335
|
+
React.useEffect(() => {
|
|
336
|
+
function handleClickOutside(event: MouseEvent) {
|
|
337
|
+
if (snoozeMenuRef.current && !snoozeMenuRef.current.contains(event.target as Node)) {
|
|
338
|
+
setShowSnoozeMenu(false)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
document.addEventListener("mousedown", handleClickOutside)
|
|
342
|
+
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
343
|
+
}, [])
|
|
344
|
+
|
|
345
|
+
const stackStyles = isStacked
|
|
346
|
+
? {
|
|
347
|
+
transform: `translateY(${stackIndex * 8}px) scale(${1 - stackIndex * 0.02})`,
|
|
348
|
+
zIndex: 100 - stackIndex,
|
|
349
|
+
opacity: 1 - stackIndex * 0.15,
|
|
350
|
+
}
|
|
351
|
+
: {}
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<div
|
|
355
|
+
className={cn(
|
|
356
|
+
"relative w-full rounded-lg border-l-4 shadow-lg transition-all duration-300",
|
|
357
|
+
config.borderColor,
|
|
358
|
+
config.bgColor,
|
|
359
|
+
"ring-1",
|
|
360
|
+
config.ringColor,
|
|
361
|
+
alert.acknowledged && "opacity-60",
|
|
362
|
+
isSnoozed && "opacity-50",
|
|
363
|
+
animated && "animate-in slide-in-from-right-5 fade-in duration-300",
|
|
364
|
+
isStacked && stackIndex > 0 && "absolute top-0 left-0"
|
|
365
|
+
)}
|
|
366
|
+
style={stackStyles}
|
|
367
|
+
>
|
|
368
|
+
{/* Main Content */}
|
|
369
|
+
<div className="p-4">
|
|
370
|
+
{/* Header Row */}
|
|
371
|
+
<div className="flex items-start gap-3">
|
|
372
|
+
{/* Icon */}
|
|
373
|
+
<div
|
|
374
|
+
className={cn(
|
|
375
|
+
"flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center",
|
|
376
|
+
config.bgColor,
|
|
377
|
+
"ring-2",
|
|
378
|
+
config.ringColor
|
|
379
|
+
)}
|
|
380
|
+
>
|
|
381
|
+
<Icon className={cn("h-5 w-5", config.color)} />
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
{/* Content */}
|
|
385
|
+
<div className="flex-1 min-w-0">
|
|
386
|
+
<div className="flex items-start justify-between gap-2">
|
|
387
|
+
<div className="flex-1">
|
|
388
|
+
<div className="flex items-center gap-2">
|
|
389
|
+
<h4 className="font-semibold text-sm text-foreground">
|
|
390
|
+
{alert.title}
|
|
391
|
+
</h4>
|
|
392
|
+
{alert.count && alert.count > 1 && (
|
|
393
|
+
<span
|
|
394
|
+
className={cn(
|
|
395
|
+
"inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-xs font-medium",
|
|
396
|
+
config.bgColor,
|
|
397
|
+
config.color,
|
|
398
|
+
"ring-1",
|
|
399
|
+
config.ringColor
|
|
400
|
+
)}
|
|
401
|
+
>
|
|
402
|
+
{alert.count}
|
|
403
|
+
</span>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
{alert.source && (
|
|
407
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
408
|
+
{alert.source}
|
|
409
|
+
</p>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
{/* Dismiss Button */}
|
|
414
|
+
{onDismiss && (
|
|
415
|
+
<button
|
|
416
|
+
onClick={() => onDismiss(alert.id)}
|
|
417
|
+
className="flex-shrink-0 p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
|
418
|
+
aria-label="Dismiss alert"
|
|
419
|
+
>
|
|
420
|
+
<X className="h-4 w-4 text-muted-foreground" />
|
|
421
|
+
</button>
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
{/* Timestamp and Duration */}
|
|
426
|
+
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
|
427
|
+
<span>{formatRelativeTime(alert.timestamp)}</span>
|
|
428
|
+
<span className="text-muted-foreground/50">|</span>
|
|
429
|
+
<span className="flex items-center gap-1">
|
|
430
|
+
<Clock className="h-3 w-3" />
|
|
431
|
+
{formatDuration(alert.timestamp)}
|
|
432
|
+
</span>
|
|
433
|
+
{isSnoozed && alert.snoozedUntil && (
|
|
434
|
+
<>
|
|
435
|
+
<span className="text-muted-foreground/50">|</span>
|
|
436
|
+
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
|
437
|
+
<BellOff className="h-3 w-3" />
|
|
438
|
+
{formatSnoozeRemaining(alert.snoozedUntil)}
|
|
439
|
+
</span>
|
|
440
|
+
</>
|
|
441
|
+
)}
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
{/* Acknowledged Info */}
|
|
445
|
+
{alert.acknowledged && (
|
|
446
|
+
<div className="flex items-center gap-1 mt-1 text-xs text-green-600 dark:text-green-400">
|
|
447
|
+
<Check className="h-3 w-3" />
|
|
448
|
+
<span>
|
|
449
|
+
Acknowledged
|
|
450
|
+
{alert.acknowledgedBy && ` by ${alert.acknowledgedBy}`}
|
|
451
|
+
</span>
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
{/* Description (Expandable) */}
|
|
458
|
+
{alert.description && (
|
|
459
|
+
<div className="mt-3">
|
|
460
|
+
<button
|
|
461
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
462
|
+
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
463
|
+
>
|
|
464
|
+
{isExpanded ? (
|
|
465
|
+
<ChevronUp className="h-3 w-3" />
|
|
466
|
+
) : (
|
|
467
|
+
<ChevronDown className="h-3 w-3" />
|
|
468
|
+
)}
|
|
469
|
+
{isExpanded ? "Hide details" : "Show details"}
|
|
470
|
+
</button>
|
|
471
|
+
|
|
472
|
+
{isExpanded && (
|
|
473
|
+
<div className="mt-2 p-3 rounded-md bg-black/5 dark:bg-white/5 animate-in slide-in-from-top-2 fade-in duration-200">
|
|
474
|
+
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
|
475
|
+
{alert.description}
|
|
476
|
+
</p>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
|
|
482
|
+
{/* Action Buttons */}
|
|
483
|
+
{(!alert.acknowledged || onSnooze) && (
|
|
484
|
+
<div className="flex items-center gap-2 mt-3">
|
|
485
|
+
{/* Acknowledge Button */}
|
|
486
|
+
{onAcknowledge && !alert.acknowledged && (
|
|
487
|
+
<button
|
|
488
|
+
onClick={() => onAcknowledge(alert.id)}
|
|
489
|
+
className={cn(
|
|
490
|
+
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
|
|
491
|
+
"bg-green-100 text-green-700 hover:bg-green-200",
|
|
492
|
+
"dark:bg-green-900/50 dark:text-green-300 dark:hover:bg-green-900/70"
|
|
493
|
+
)}
|
|
494
|
+
>
|
|
495
|
+
<Check className="h-3.5 w-3.5" />
|
|
496
|
+
Acknowledge
|
|
497
|
+
</button>
|
|
498
|
+
)}
|
|
499
|
+
|
|
500
|
+
{/* Snooze Button */}
|
|
501
|
+
{onSnooze && !isSnoozed && (
|
|
502
|
+
<div className="relative" ref={snoozeMenuRef}>
|
|
503
|
+
<button
|
|
504
|
+
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
|
|
505
|
+
className={cn(
|
|
506
|
+
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
|
|
507
|
+
"bg-amber-100 text-amber-700 hover:bg-amber-200",
|
|
508
|
+
"dark:bg-amber-900/50 dark:text-amber-300 dark:hover:bg-amber-900/70"
|
|
509
|
+
)}
|
|
510
|
+
>
|
|
511
|
+
<BellOff className="h-3.5 w-3.5" />
|
|
512
|
+
Snooze
|
|
513
|
+
<ChevronDown className="h-3 w-3" />
|
|
514
|
+
</button>
|
|
515
|
+
|
|
516
|
+
{/* Snooze Menu */}
|
|
517
|
+
{showSnoozeMenu && (
|
|
518
|
+
<div className="absolute bottom-full left-0 mb-1 py-1 bg-popover border rounded-md shadow-lg min-w-[120px] z-50 animate-in slide-in-from-bottom-2 fade-in duration-150">
|
|
519
|
+
{snoozeDurations.map((duration) => (
|
|
520
|
+
<button
|
|
521
|
+
key={duration.value}
|
|
522
|
+
onClick={() => {
|
|
523
|
+
onSnooze(alert.id, duration.value)
|
|
524
|
+
setShowSnoozeMenu(false)
|
|
525
|
+
}}
|
|
526
|
+
className="w-full px-3 py-1.5 text-xs text-left hover:bg-muted transition-colors"
|
|
527
|
+
>
|
|
528
|
+
{duration.label}
|
|
529
|
+
</button>
|
|
530
|
+
))}
|
|
531
|
+
</div>
|
|
532
|
+
)}
|
|
533
|
+
</div>
|
|
534
|
+
)}
|
|
535
|
+
</div>
|
|
536
|
+
)}
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
interface StackedAlertsPreviewProps {
|
|
543
|
+
alerts: Alert[]
|
|
544
|
+
maxVisible: number
|
|
545
|
+
onExpand: () => void
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function StackedAlertsPreview({
|
|
549
|
+
alerts,
|
|
550
|
+
maxVisible,
|
|
551
|
+
onExpand,
|
|
552
|
+
}: StackedAlertsPreviewProps) {
|
|
553
|
+
const visibleAlerts = alerts.slice(0, maxVisible)
|
|
554
|
+
const hiddenCount = alerts.length - maxVisible
|
|
555
|
+
|
|
556
|
+
return (
|
|
557
|
+
<div className="relative">
|
|
558
|
+
{/* Stacked cards effect */}
|
|
559
|
+
<div className="relative" style={{ height: `${56 + (Math.min(maxVisible, alerts.length) - 1) * 8}px` }}>
|
|
560
|
+
{visibleAlerts.map((alert, index) => {
|
|
561
|
+
const config = severityConfig[alert.severity]
|
|
562
|
+
const Icon = config.icon
|
|
563
|
+
|
|
564
|
+
return (
|
|
565
|
+
<div
|
|
566
|
+
key={alert.id}
|
|
567
|
+
className={cn(
|
|
568
|
+
"absolute left-0 right-0 h-14 rounded-lg border-l-4 shadow-md transition-all duration-300",
|
|
569
|
+
config.borderColor,
|
|
570
|
+
config.bgColor,
|
|
571
|
+
"ring-1",
|
|
572
|
+
config.ringColor,
|
|
573
|
+
"cursor-pointer hover:translate-y-[-2px]"
|
|
574
|
+
)}
|
|
575
|
+
style={{
|
|
576
|
+
transform: `translateY(${index * 8}px) scale(${1 - index * 0.02})`,
|
|
577
|
+
zIndex: 100 - index,
|
|
578
|
+
opacity: 1 - index * 0.15,
|
|
579
|
+
}}
|
|
580
|
+
onClick={onExpand}
|
|
581
|
+
>
|
|
582
|
+
<div className="flex items-center gap-3 p-3 h-full">
|
|
583
|
+
<Icon className={cn("h-5 w-5 flex-shrink-0", config.color)} />
|
|
584
|
+
<div className="flex-1 min-w-0">
|
|
585
|
+
<p className="text-sm font-medium truncate">{alert.title}</p>
|
|
586
|
+
<p className="text-xs text-muted-foreground">
|
|
587
|
+
{formatRelativeTime(alert.timestamp)}
|
|
588
|
+
</p>
|
|
589
|
+
</div>
|
|
590
|
+
{alert.count && alert.count > 1 && (
|
|
591
|
+
<span
|
|
592
|
+
className={cn(
|
|
593
|
+
"flex-shrink-0 inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-xs font-medium",
|
|
594
|
+
config.color
|
|
595
|
+
)}
|
|
596
|
+
>
|
|
597
|
+
{alert.count}
|
|
598
|
+
</span>
|
|
599
|
+
)}
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
)
|
|
603
|
+
})}
|
|
604
|
+
</div>
|
|
605
|
+
|
|
606
|
+
{/* Hidden count badge */}
|
|
607
|
+
{hiddenCount > 0 && (
|
|
608
|
+
<button
|
|
609
|
+
onClick={onExpand}
|
|
610
|
+
className="absolute -bottom-2 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-muted rounded-full text-xs font-medium text-muted-foreground hover:bg-muted/80 transition-colors shadow-sm"
|
|
611
|
+
>
|
|
612
|
+
+{hiddenCount} more
|
|
613
|
+
</button>
|
|
614
|
+
)}
|
|
615
|
+
</div>
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ============================================
|
|
620
|
+
// MAIN COMPONENT
|
|
621
|
+
// ============================================
|
|
622
|
+
|
|
623
|
+
export function WakaAlertStack({
|
|
624
|
+
alerts,
|
|
625
|
+
onAcknowledge,
|
|
626
|
+
onSnooze,
|
|
627
|
+
onDismiss,
|
|
628
|
+
maxVisible = 5,
|
|
629
|
+
groupSimilar = true,
|
|
630
|
+
animated = true,
|
|
631
|
+
className,
|
|
632
|
+
}: WakaAlertStackProps) {
|
|
633
|
+
const [isExpanded, setIsExpanded] = React.useState(false)
|
|
634
|
+
|
|
635
|
+
// Process and sort alerts by priority
|
|
636
|
+
const processedAlerts = React.useMemo(() => {
|
|
637
|
+
const now = new Date()
|
|
638
|
+
|
|
639
|
+
// Filter out snoozed alerts
|
|
640
|
+
let filteredAlerts = alerts.filter((a) => {
|
|
641
|
+
if (a.snoozedUntil && a.snoozedUntil > now) return false
|
|
642
|
+
return true
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// Group similar alerts if enabled
|
|
646
|
+
if (groupSimilar) {
|
|
647
|
+
const grouped = new Map<string, Alert>()
|
|
648
|
+
|
|
649
|
+
filteredAlerts.forEach((alert) => {
|
|
650
|
+
const key = `${alert.severity}-${alert.title}-${alert.source || ""}`
|
|
651
|
+
const existing = grouped.get(key)
|
|
652
|
+
|
|
653
|
+
if (existing) {
|
|
654
|
+
// Update count and use most recent timestamp
|
|
655
|
+
grouped.set(key, {
|
|
656
|
+
...existing,
|
|
657
|
+
count: (existing.count || 1) + 1,
|
|
658
|
+
timestamp:
|
|
659
|
+
alert.timestamp > existing.timestamp
|
|
660
|
+
? alert.timestamp
|
|
661
|
+
: existing.timestamp,
|
|
662
|
+
})
|
|
663
|
+
} else {
|
|
664
|
+
grouped.set(key, { ...alert, count: alert.count || 1 })
|
|
665
|
+
}
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
filteredAlerts = Array.from(grouped.values())
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Sort by priority (critical first) then by timestamp (newest first)
|
|
672
|
+
return filteredAlerts.sort((a, b) => {
|
|
673
|
+
const priorityDiff =
|
|
674
|
+
severityConfig[b.severity].priority -
|
|
675
|
+
severityConfig[a.severity].priority
|
|
676
|
+
if (priorityDiff !== 0) return priorityDiff
|
|
677
|
+
return b.timestamp.getTime() - a.timestamp.getTime()
|
|
678
|
+
})
|
|
679
|
+
}, [alerts, groupSimilar])
|
|
680
|
+
|
|
681
|
+
// Get snoozed alerts for display
|
|
682
|
+
const snoozedAlerts = React.useMemo(() => {
|
|
683
|
+
const now = new Date()
|
|
684
|
+
return alerts.filter((a) => a.snoozedUntil && a.snoozedUntil > now)
|
|
685
|
+
}, [alerts])
|
|
686
|
+
|
|
687
|
+
// Summary stats
|
|
688
|
+
const stats = React.useMemo(() => {
|
|
689
|
+
return {
|
|
690
|
+
critical: processedAlerts.filter((a) => a.severity === "critical").length,
|
|
691
|
+
warning: processedAlerts.filter((a) => a.severity === "warning").length,
|
|
692
|
+
info: processedAlerts.filter((a) => a.severity === "info").length,
|
|
693
|
+
snoozed: snoozedAlerts.length,
|
|
694
|
+
}
|
|
695
|
+
}, [processedAlerts, snoozedAlerts])
|
|
696
|
+
|
|
697
|
+
if (alerts.length === 0) {
|
|
698
|
+
return (
|
|
699
|
+
<div
|
|
700
|
+
className={cn(
|
|
701
|
+
"flex flex-col items-center justify-center py-8 text-center rounded-lg border border-dashed",
|
|
702
|
+
className
|
|
703
|
+
)}
|
|
704
|
+
>
|
|
705
|
+
<Bell className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
|
706
|
+
<p className="text-sm text-muted-foreground">No active alerts</p>
|
|
707
|
+
</div>
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return (
|
|
712
|
+
<div className={cn("w-full", className)}>
|
|
713
|
+
{/* Summary Header */}
|
|
714
|
+
<div className="flex items-center justify-between mb-4">
|
|
715
|
+
<div className="flex items-center gap-3">
|
|
716
|
+
<h3 className="font-semibold text-sm">Alerts</h3>
|
|
717
|
+
<div className="flex items-center gap-2">
|
|
718
|
+
{stats.critical > 0 && (
|
|
719
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300">
|
|
720
|
+
<AlertTriangle className="h-3 w-3" />
|
|
721
|
+
{stats.critical}
|
|
722
|
+
</span>
|
|
723
|
+
)}
|
|
724
|
+
{stats.warning > 0 && (
|
|
725
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
|
726
|
+
<AlertCircle className="h-3 w-3" />
|
|
727
|
+
{stats.warning}
|
|
728
|
+
</span>
|
|
729
|
+
)}
|
|
730
|
+
{stats.info > 0 && (
|
|
731
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
|
|
732
|
+
<Info className="h-3 w-3" />
|
|
733
|
+
{stats.info}
|
|
734
|
+
</span>
|
|
735
|
+
)}
|
|
736
|
+
{stats.snoozed > 0 && (
|
|
737
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground">
|
|
738
|
+
<BellOff className="h-3 w-3" />
|
|
739
|
+
{stats.snoozed}
|
|
740
|
+
</span>
|
|
741
|
+
)}
|
|
742
|
+
</div>
|
|
743
|
+
</div>
|
|
744
|
+
|
|
745
|
+
{/* Expand/Collapse Toggle */}
|
|
746
|
+
{processedAlerts.length > 1 && (
|
|
747
|
+
<button
|
|
748
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
749
|
+
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
|
750
|
+
>
|
|
751
|
+
{isExpanded ? (
|
|
752
|
+
<>
|
|
753
|
+
<ChevronUp className="h-3 w-3" />
|
|
754
|
+
Collapse
|
|
755
|
+
</>
|
|
756
|
+
) : (
|
|
757
|
+
<>
|
|
758
|
+
<ChevronDown className="h-3 w-3" />
|
|
759
|
+
Expand all
|
|
760
|
+
</>
|
|
761
|
+
)}
|
|
762
|
+
</button>
|
|
763
|
+
)}
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
{/* Alert Stack */}
|
|
767
|
+
{isExpanded ? (
|
|
768
|
+
// Expanded view - show all alerts
|
|
769
|
+
<div className="space-y-3">
|
|
770
|
+
{processedAlerts.map((alert, index) => (
|
|
771
|
+
<AlertCard
|
|
772
|
+
key={alert.id}
|
|
773
|
+
alert={alert}
|
|
774
|
+
onAcknowledge={onAcknowledge}
|
|
775
|
+
onSnooze={onSnooze}
|
|
776
|
+
onDismiss={onDismiss}
|
|
777
|
+
animated={animated}
|
|
778
|
+
/>
|
|
779
|
+
))}
|
|
780
|
+
|
|
781
|
+
{/* Snoozed Alerts Section */}
|
|
782
|
+
{snoozedAlerts.length > 0 && (
|
|
783
|
+
<div className="pt-3 border-t">
|
|
784
|
+
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
|
785
|
+
<BellOff className="h-3 w-3" />
|
|
786
|
+
Snoozed ({snoozedAlerts.length})
|
|
787
|
+
</p>
|
|
788
|
+
<div className="space-y-2">
|
|
789
|
+
{snoozedAlerts.map((alert) => (
|
|
790
|
+
<AlertCard
|
|
791
|
+
key={alert.id}
|
|
792
|
+
alert={alert}
|
|
793
|
+
onAcknowledge={onAcknowledge}
|
|
794
|
+
onSnooze={onSnooze}
|
|
795
|
+
onDismiss={onDismiss}
|
|
796
|
+
animated={false}
|
|
797
|
+
/>
|
|
798
|
+
))}
|
|
799
|
+
</div>
|
|
800
|
+
</div>
|
|
801
|
+
)}
|
|
802
|
+
</div>
|
|
803
|
+
) : (
|
|
804
|
+
// Stacked view
|
|
805
|
+
<>
|
|
806
|
+
{processedAlerts.length === 1 ? (
|
|
807
|
+
<AlertCard
|
|
808
|
+
alert={processedAlerts[0]}
|
|
809
|
+
onAcknowledge={onAcknowledge}
|
|
810
|
+
onSnooze={onSnooze}
|
|
811
|
+
onDismiss={onDismiss}
|
|
812
|
+
animated={animated}
|
|
813
|
+
/>
|
|
814
|
+
) : (
|
|
815
|
+
<StackedAlertsPreview
|
|
816
|
+
alerts={processedAlerts}
|
|
817
|
+
maxVisible={maxVisible}
|
|
818
|
+
onExpand={() => setIsExpanded(true)}
|
|
819
|
+
/>
|
|
820
|
+
)}
|
|
821
|
+
</>
|
|
822
|
+
)}
|
|
823
|
+
</div>
|
|
824
|
+
)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export default WakaAlertStack
|