@checkstack/ui 0.0.2

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/bunfig.toml +2 -0
  3. package/package.json +40 -0
  4. package/src/components/Accordion.tsx +55 -0
  5. package/src/components/Alert.tsx +90 -0
  6. package/src/components/AmbientBackground.tsx +105 -0
  7. package/src/components/AnimatedCounter.tsx +54 -0
  8. package/src/components/BackLink.tsx +56 -0
  9. package/src/components/Badge.tsx +38 -0
  10. package/src/components/Button.tsx +55 -0
  11. package/src/components/Card.tsx +56 -0
  12. package/src/components/Checkbox.tsx +46 -0
  13. package/src/components/ColorPicker.tsx +69 -0
  14. package/src/components/CommandPalette.tsx +74 -0
  15. package/src/components/ConfirmationModal.tsx +134 -0
  16. package/src/components/DateRangeFilter.tsx +128 -0
  17. package/src/components/DateTimePicker.tsx +65 -0
  18. package/src/components/Dialog.tsx +134 -0
  19. package/src/components/DropdownMenu.tsx +96 -0
  20. package/src/components/DynamicForm/DynamicForm.tsx +126 -0
  21. package/src/components/DynamicForm/DynamicOptionsField.tsx +220 -0
  22. package/src/components/DynamicForm/FormField.tsx +690 -0
  23. package/src/components/DynamicForm/JsonField.tsx +98 -0
  24. package/src/components/DynamicForm/index.ts +11 -0
  25. package/src/components/DynamicForm/types.ts +95 -0
  26. package/src/components/DynamicForm/utils.ts +39 -0
  27. package/src/components/DynamicIcon.tsx +45 -0
  28. package/src/components/EditableText.tsx +141 -0
  29. package/src/components/EmptyState.tsx +32 -0
  30. package/src/components/HealthBadge.tsx +57 -0
  31. package/src/components/InfoBanner.tsx +97 -0
  32. package/src/components/Input.tsx +20 -0
  33. package/src/components/Label.tsx +17 -0
  34. package/src/components/LoadingSpinner.tsx +29 -0
  35. package/src/components/Markdown.tsx +206 -0
  36. package/src/components/NavItem.tsx +112 -0
  37. package/src/components/Page.tsx +58 -0
  38. package/src/components/PageLayout.tsx +83 -0
  39. package/src/components/PaginatedList.tsx +135 -0
  40. package/src/components/Pagination.tsx +195 -0
  41. package/src/components/PermissionDenied.tsx +31 -0
  42. package/src/components/PermissionGate.tsx +97 -0
  43. package/src/components/PluginConfigForm.tsx +91 -0
  44. package/src/components/SectionHeader.tsx +30 -0
  45. package/src/components/Select.tsx +157 -0
  46. package/src/components/StatusCard.tsx +78 -0
  47. package/src/components/StatusUpdateTimeline.tsx +222 -0
  48. package/src/components/StrategyConfigCard.tsx +333 -0
  49. package/src/components/SubscribeButton.tsx +96 -0
  50. package/src/components/Table.tsx +119 -0
  51. package/src/components/Tabs.tsx +141 -0
  52. package/src/components/TemplateEditor.test.ts +156 -0
  53. package/src/components/TemplateEditor.tsx +435 -0
  54. package/src/components/TerminalFeed.tsx +152 -0
  55. package/src/components/Textarea.tsx +22 -0
  56. package/src/components/ThemeProvider.tsx +76 -0
  57. package/src/components/Toast.tsx +118 -0
  58. package/src/components/ToastProvider.tsx +126 -0
  59. package/src/components/Toggle.tsx +47 -0
  60. package/src/components/Tooltip.tsx +20 -0
  61. package/src/components/UserMenu.tsx +79 -0
  62. package/src/hooks/usePagination.e2e.ts +275 -0
  63. package/src/hooks/usePagination.ts +231 -0
  64. package/src/index.ts +53 -0
  65. package/src/themes.css +204 -0
  66. package/src/utils/strip-markdown.ts +44 -0
  67. package/src/utils.ts +8 -0
  68. package/tsconfig.json +6 -0
@@ -0,0 +1,222 @@
1
+ import React from "react";
2
+ import { Calendar } from "lucide-react";
3
+ import { format } from "date-fns";
4
+ import { Badge } from "./Badge";
5
+ import { EmptyState } from "./EmptyState";
6
+
7
+ export interface TimelineItem {
8
+ /** Unique identifier for the item */
9
+ id: string;
10
+ /** Date/time of the item for sorting */
11
+ date: Date | string;
12
+ }
13
+
14
+ export interface TimelineProps<T extends TimelineItem> {
15
+ /** Array of timeline items to display */
16
+ items: T[];
17
+ /** Render function for each timeline item's content */
18
+ renderItem: (item: T, index: number) => React.ReactNode;
19
+ /** Optional render function for the dot indicator. Return null to use default dot. */
20
+ renderDot?: (item: T, index: number) => React.ReactNode;
21
+ /** Sort order for items. Defaults to "desc" (newest first) */
22
+ sortOrder?: "asc" | "desc";
23
+ /** Empty state title */
24
+ emptyTitle?: string;
25
+ /** Empty state description */
26
+ emptyDescription?: string;
27
+ /** Maximum height before scrolling (CSS value or Tailwind class) */
28
+ maxHeight?: string;
29
+ /** Whether to show the timeline dots and line. Defaults to true */
30
+ showTimeline?: boolean;
31
+ /** Custom className for the container */
32
+ className?: string;
33
+ }
34
+
35
+ /**
36
+ * A generic timeline component for displaying chronological items.
37
+ * Uses render props for full customization of item content.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * <Timeline
42
+ * items={updates}
43
+ * renderItem={(update) => (
44
+ * <div className="p-4 rounded-lg border">
45
+ * <p>{update.message}</p>
46
+ * <span className="text-xs text-muted-foreground">
47
+ * {format(new Date(update.date), "PPpp")}
48
+ * </span>
49
+ * </div>
50
+ * )}
51
+ * emptyTitle="No updates"
52
+ * />
53
+ * ```
54
+ */
55
+ export function Timeline<T extends TimelineItem>({
56
+ items,
57
+ renderItem,
58
+ renderDot,
59
+ sortOrder = "desc",
60
+ emptyTitle = "No items",
61
+ emptyDescription = "No items to display.",
62
+ maxHeight,
63
+ showTimeline = true,
64
+ className = "",
65
+ }: TimelineProps<T>): React.ReactElement {
66
+ if (items.length === 0) {
67
+ return <EmptyState title={emptyTitle} description={emptyDescription} />;
68
+ }
69
+
70
+ // Sort items by date
71
+ const sortedItems = [...items].toSorted((a, b) => {
72
+ const diff = new Date(b.date).getTime() - new Date(a.date).getTime();
73
+ return sortOrder === "desc" ? diff : -diff;
74
+ });
75
+
76
+ const containerClass = maxHeight
77
+ ? `overflow-y-auto ${
78
+ maxHeight.startsWith("max-h-") ? maxHeight : `max-h-[${maxHeight}]`
79
+ }`
80
+ : "";
81
+
82
+ const defaultDot = (index: number) => (
83
+ <div
84
+ className={`absolute left-2.5 w-3 h-3 rounded-full border-2 border-background ${
85
+ index === 0 ? "bg-primary" : "bg-muted-foreground/30"
86
+ }`}
87
+ />
88
+ );
89
+
90
+ return (
91
+ <div className={`${containerClass} ${className}`.trim()}>
92
+ {showTimeline ? (
93
+ <div className="relative">
94
+ {/* Timeline line */}
95
+ <div className="absolute left-4 top-2 bottom-2 w-0.5 bg-border" />
96
+
97
+ <div className="space-y-6">
98
+ {sortedItems.map((item, index) => (
99
+ <div key={item.id} className="relative pl-10">
100
+ {/* Timeline dot */}
101
+ {renderDot ? renderDot(item, index) : defaultDot(index)}
102
+ {renderItem(item, index)}
103
+ </div>
104
+ ))}
105
+ </div>
106
+ </div>
107
+ ) : (
108
+ <div className="space-y-3">
109
+ {sortedItems.map((item, index) => (
110
+ <div key={item.id}>{renderItem(item, index)}</div>
111
+ ))}
112
+ </div>
113
+ )}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ // ============================================================================
119
+ // Convenience wrapper for status updates (common use case)
120
+ // ============================================================================
121
+
122
+ export interface StatusUpdate<TStatus extends string = string> {
123
+ id: string;
124
+ message: string;
125
+ statusChange?: TStatus;
126
+ createdAt: Date | string;
127
+ createdBy?: string;
128
+ }
129
+
130
+ export interface StatusUpdateTimelineProps<
131
+ TStatus extends string,
132
+ T extends StatusUpdate<TStatus>
133
+ > {
134
+ /** Array of status updates to display */
135
+ updates: T[];
136
+ /** Render function for status badge. Receives the exact status type from T. */
137
+ renderStatusBadge?: (status: TStatus) => React.ReactNode;
138
+ /** Empty state title */
139
+ emptyTitle?: string;
140
+ /** Empty state description */
141
+ emptyDescription?: string;
142
+ /** Maximum height before scrolling */
143
+ maxHeight?: string;
144
+ /** Whether to show the timeline dots and line */
145
+ showTimeline?: boolean;
146
+ /** Custom className for the container */
147
+ className?: string;
148
+ }
149
+
150
+ /**
151
+ * A specialized timeline for status updates.
152
+ * Wraps the generic Timeline component with status-update-specific rendering.
153
+ * Uses generics to preserve the status type from the update items.
154
+ */
155
+ export function StatusUpdateTimeline<
156
+ TStatus extends string,
157
+ T extends StatusUpdate<TStatus>
158
+ >({
159
+ updates,
160
+ renderStatusBadge,
161
+ emptyTitle = "No status updates",
162
+ emptyDescription = "No status updates have been posted yet.",
163
+ maxHeight,
164
+ showTimeline = true,
165
+ className = "",
166
+ }: StatusUpdateTimelineProps<TStatus, T>): React.ReactElement {
167
+ const defaultRenderStatusBadge = (status: TStatus) => (
168
+ <Badge variant="secondary">{status}</Badge>
169
+ );
170
+
171
+ const renderBadge = renderStatusBadge ?? defaultRenderStatusBadge;
172
+
173
+ // Map updates to timeline items (StatusUpdate uses createdAt, TimelineItem uses date)
174
+ const timelineItems = updates.map((update) => ({
175
+ ...update,
176
+ date: update.createdAt,
177
+ }));
178
+
179
+ return (
180
+ <Timeline
181
+ items={timelineItems}
182
+ emptyTitle={emptyTitle}
183
+ emptyDescription={emptyDescription}
184
+ maxHeight={maxHeight}
185
+ showTimeline={showTimeline}
186
+ className={className}
187
+ renderItem={(update) => (
188
+ <div
189
+ className={
190
+ showTimeline
191
+ ? "p-4 rounded-lg border border-border bg-muted/20"
192
+ : "p-3 bg-muted/20 rounded-lg border text-sm"
193
+ }
194
+ >
195
+ <div className="flex items-start justify-between gap-2 mb-2">
196
+ <p className="text-foreground">{update.message}</p>
197
+ {update.statusChange && (
198
+ <div className="shrink-0">
199
+ {renderBadge(update.statusChange as TStatus)}
200
+ </div>
201
+ )}
202
+ </div>
203
+ <div className="flex items-center gap-1 text-xs text-muted-foreground">
204
+ <Calendar className="h-3 w-3" />
205
+ <span>
206
+ {format(
207
+ new Date(update.date),
208
+ showTimeline ? "MMM d, yyyy 'at' HH:mm" : "MMM d, HH:mm"
209
+ )}
210
+ </span>
211
+ {update.createdBy && (
212
+ <>
213
+ <span>•</span>
214
+ <span>by {update.createdBy}</span>
215
+ </>
216
+ )}
217
+ </div>
218
+ </div>
219
+ )}
220
+ />
221
+ );
222
+ }
@@ -0,0 +1,333 @@
1
+ import { useState } from "react";
2
+ import { Power, ChevronDown, ChevronRight, AlertCircle } from "lucide-react";
3
+ import { Card, CardHeader, CardContent } from "./Card";
4
+ import { Button } from "./Button";
5
+ import { Badge, type BadgeProps } from "./Badge";
6
+ import { Toggle } from "./Toggle";
7
+ import { DynamicForm } from "./DynamicForm";
8
+ import { DynamicIcon, type LucideIconName } from "./DynamicIcon";
9
+ import { MarkdownBlock } from "./Markdown";
10
+ import { cn } from "../utils";
11
+
12
+ /**
13
+ * A configuration section that can be displayed in the card
14
+ */
15
+ export interface ConfigSection {
16
+ /** Unique identifier for this section */
17
+ id: string;
18
+ /** Section title (e.g., "Configuration", "Layout Settings") */
19
+ title: string;
20
+ /** JSON Schema for the configuration form */
21
+ schema: Record<string, unknown>;
22
+ /** Current configuration values */
23
+ value?: Record<string, unknown>;
24
+ /** Called when configuration is saved */
25
+ onSave?: (config: Record<string, unknown>) => Promise<void>;
26
+ }
27
+
28
+ /**
29
+ * Base strategy data that can be displayed in the card
30
+ */
31
+ export interface StrategyConfigCardData {
32
+ /** Unique identifier for the strategy */
33
+ id: string;
34
+ /** Display name shown in the header */
35
+ displayName: string;
36
+ /** Optional description shown below the title */
37
+ description?: string;
38
+ /** Lucide icon name in PascalCase (e.g., 'AlertCircle', 'HeartPulse') */
39
+ icon?: LucideIconName;
40
+ /** Whether the strategy is currently enabled */
41
+ enabled: boolean;
42
+ }
43
+
44
+ export interface StrategyConfigCardProps {
45
+ /** The strategy data to display */
46
+ strategy: StrategyConfigCardData;
47
+ /**
48
+ * Configuration sections to display when expanded.
49
+ * Each section has its own schema, values, and save handler.
50
+ */
51
+ configSections?: ConfigSection[];
52
+ /**
53
+ * Called when the enabled state changes
54
+ * @returns Promise that resolves when the update is complete
55
+ */
56
+ onToggle?: (id: string, enabled: boolean) => Promise<void>;
57
+ /** Whether save/toggle operations are in progress */
58
+ saving?: boolean;
59
+ /** Additional badges to show after the title */
60
+ badges?: Array<{
61
+ label: string;
62
+ variant?: BadgeProps["variant"];
63
+ className?: string;
64
+ }>;
65
+ /** Optional warning message shown when strategy is disabled but expanded */
66
+ disabledWarning?: string;
67
+ /** Whether toggle should be disabled (e.g., config required first) */
68
+ toggleDisabled?: boolean;
69
+ /** Controls whether the card is expanded (controlled mode) */
70
+ expanded?: boolean;
71
+ /** Called when expansion state changes (controlled mode) */
72
+ onExpandedChange?: (expanded: boolean) => void;
73
+ /** Optional subtitle shown below description (e.g., "From: plugin-name") */
74
+ subtitle?: string;
75
+ /** Use Toggle switch instead of Button for enable/disable */
76
+ useToggleSwitch?: boolean;
77
+ /**
78
+ * Whether config is missing (has schema but no saved config).
79
+ * When true, shows "Needs Configuration" badge and disables toggle.
80
+ */
81
+ configMissing?: boolean;
82
+ /**
83
+ * Markdown instructions to show when expanded.
84
+ * Rendered before the configuration sections.
85
+ */
86
+ instructions?: string;
87
+ }
88
+
89
+ /**
90
+ * Shared component for configuring strategies (auth, notification, etc.)
91
+ * Provides a consistent accordion-style card with enable/disable toggle,
92
+ * expandable configuration sections, and save functionality.
93
+ */
94
+ export function StrategyConfigCard({
95
+ strategy,
96
+ configSections = [],
97
+ onToggle,
98
+ saving,
99
+ badges = [],
100
+ disabledWarning,
101
+ toggleDisabled,
102
+ expanded: controlledExpanded,
103
+ onExpandedChange,
104
+ subtitle,
105
+ useToggleSwitch = false,
106
+ configMissing = false,
107
+ instructions,
108
+ }: StrategyConfigCardProps) {
109
+ // Internal state for uncontrolled mode
110
+ const [internalExpanded, setInternalExpanded] = useState(false);
111
+ const [localEnabled, setLocalEnabled] = useState(strategy.enabled);
112
+
113
+ // Section-specific state for form values
114
+ const [sectionValues, setSectionValues] = useState<
115
+ Record<string, Record<string, unknown>>
116
+ >(() => {
117
+ const initial: Record<string, Record<string, unknown>> = {};
118
+ for (const section of configSections) {
119
+ initial[section.id] = section.value ?? {};
120
+ }
121
+ return initial;
122
+ });
123
+
124
+ // Section-specific validation state
125
+ const [sectionValid, setSectionValid] = useState<Record<string, boolean>>(
126
+ () => {
127
+ const initial: Record<string, boolean> = {};
128
+ for (const section of configSections) {
129
+ // Start as true for existing configs
130
+ initial[section.id] = true;
131
+ }
132
+ return initial;
133
+ }
134
+ );
135
+
136
+ // Determine if we're in controlled or uncontrolled mode
137
+ const isControlled = controlledExpanded !== undefined;
138
+ const expanded = isControlled ? controlledExpanded : internalExpanded;
139
+ const setExpanded = isControlled
140
+ ? (value: boolean) => onExpandedChange?.(value)
141
+ : setInternalExpanded;
142
+
143
+ // Check if there are sections to show
144
+ const hasSections = configSections.length > 0;
145
+
146
+ // Build final badges array - add "Needs Configuration" if config is missing
147
+ const finalBadges = [
148
+ ...badges,
149
+ ...(configMissing
150
+ ? [{ label: "Needs Configuration", variant: "warning" as const }]
151
+ : []),
152
+ ];
153
+
154
+ // Toggle should be disabled if config is missing (must configure first)
155
+ const isToggleDisabled = saving || toggleDisabled || configMissing;
156
+
157
+ const handleToggle = async (newEnabled: boolean) => {
158
+ if (onToggle) {
159
+ setLocalEnabled(newEnabled);
160
+ await onToggle(strategy.id, newEnabled);
161
+ }
162
+ };
163
+
164
+ const handleExpandClick = () => {
165
+ setExpanded(!expanded);
166
+ };
167
+
168
+ const handleSectionValueChange = (
169
+ sectionId: string,
170
+ value: Record<string, unknown>
171
+ ) => {
172
+ setSectionValues((prev) => ({ ...prev, [sectionId]: value }));
173
+ };
174
+
175
+ const handleSectionValidChange = (sectionId: string, isValid: boolean) => {
176
+ setSectionValid((prev) => ({ ...prev, [sectionId]: isValid }));
177
+ };
178
+
179
+ const handleSaveSection = async (section: ConfigSection) => {
180
+ if (section.onSave) {
181
+ await section.onSave(sectionValues[section.id] ?? {});
182
+ }
183
+ };
184
+
185
+ return (
186
+ <Card
187
+ className={cn(
188
+ "overflow-hidden transition-all",
189
+ localEnabled ? "border-primary/30" : "opacity-80"
190
+ )}
191
+ >
192
+ <CardHeader className="p-4">
193
+ <div className="flex items-center justify-between">
194
+ <div className="flex items-center gap-3 flex-1">
195
+ {/* Expand/Collapse button */}
196
+ {hasSections && (
197
+ <button
198
+ onClick={handleExpandClick}
199
+ className="text-muted-foreground hover:text-foreground transition-colors"
200
+ type="button"
201
+ >
202
+ {expanded ? (
203
+ <ChevronDown className="h-5 w-5" />
204
+ ) : (
205
+ <ChevronRight className="h-5 w-5" />
206
+ )}
207
+ </button>
208
+ )}
209
+
210
+ {/* Icon */}
211
+ <DynamicIcon
212
+ name={strategy.icon}
213
+ className="h-5 w-5 text-muted-foreground"
214
+ />
215
+
216
+ {/* Title and description */}
217
+ <div className="flex-1">
218
+ <div className="flex items-center gap-2 flex-wrap">
219
+ <span className="font-semibold">{strategy.displayName}</span>
220
+ {finalBadges.map((badge, index) => (
221
+ <Badge
222
+ key={index}
223
+ variant={badge.variant}
224
+ className={badge.className}
225
+ >
226
+ {badge.label}
227
+ </Badge>
228
+ ))}
229
+ </div>
230
+ {strategy.description && (
231
+ <p className="text-sm text-muted-foreground mt-0.5">
232
+ {strategy.description}
233
+ </p>
234
+ )}
235
+ {subtitle && (
236
+ <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
237
+ )}
238
+ </div>
239
+ </div>
240
+
241
+ {/* Enable/Disable control */}
242
+ <div className="flex items-center gap-2">
243
+ {useToggleSwitch ? (
244
+ <Toggle
245
+ checked={localEnabled}
246
+ disabled={isToggleDisabled}
247
+ onCheckedChange={(checked) => void handleToggle(checked)}
248
+ />
249
+ ) : (
250
+ <Button
251
+ variant={localEnabled ? "primary" : "outline"}
252
+ size="sm"
253
+ onClick={() => void handleToggle(!localEnabled)}
254
+ disabled={isToggleDisabled}
255
+ className="min-w-[90px]"
256
+ >
257
+ <Power
258
+ className={cn(
259
+ "h-4 w-4 mr-1",
260
+ localEnabled ? "text-green-300" : "text-muted-foreground"
261
+ )}
262
+ />
263
+ {localEnabled ? "Enabled" : "Disabled"}
264
+ </Button>
265
+ )}
266
+ </div>
267
+ </div>
268
+ </CardHeader>
269
+
270
+ {/* Expanded configuration sections */}
271
+ {expanded && hasSections && (
272
+ <CardContent className="border-t bg-muted/30 p-4 space-y-6">
273
+ {!localEnabled && disabledWarning && (
274
+ <div className="flex items-center gap-2 text-sm text-muted-foreground p-2 bg-muted rounded">
275
+ <AlertCircle className="h-4 w-4 shrink-0" />
276
+ {disabledWarning}
277
+ </div>
278
+ )}
279
+
280
+ {/* Instructions block */}
281
+ {instructions && (
282
+ <div className="p-4 bg-muted/50 rounded-lg border border-border/50">
283
+ <MarkdownBlock size="sm">{instructions}</MarkdownBlock>
284
+ </div>
285
+ )}
286
+
287
+ {configSections
288
+ .filter((section) => {
289
+ // Check if section has fields
290
+ return (
291
+ section.schema &&
292
+ "properties" in section.schema &&
293
+ Object.keys(section.schema.properties as object).length > 0
294
+ );
295
+ })
296
+ .map((section) => (
297
+ <div key={section.id}>
298
+ {/* Section title (only show if multiple sections) */}
299
+ {configSections.length > 1 && (
300
+ <h4 className="text-sm font-medium text-muted-foreground mb-3">
301
+ {section.title}
302
+ </h4>
303
+ )}
304
+
305
+ <DynamicForm
306
+ schema={section.schema}
307
+ value={sectionValues[section.id] ?? {}}
308
+ onChange={(value) =>
309
+ handleSectionValueChange(section.id, value)
310
+ }
311
+ onValidChange={(isValid) =>
312
+ handleSectionValidChange(section.id, isValid)
313
+ }
314
+ />
315
+
316
+ {section.onSave && (
317
+ <div className="mt-4 flex justify-end">
318
+ <Button
319
+ onClick={() => void handleSaveSection(section)}
320
+ disabled={saving || !sectionValid[section.id]}
321
+ size="sm"
322
+ >
323
+ {saving ? "Saving..." : `Save ${section.title}`}
324
+ </Button>
325
+ </div>
326
+ )}
327
+ </div>
328
+ ))}
329
+ </CardContent>
330
+ )}
331
+ </Card>
332
+ );
333
+ }
@@ -0,0 +1,96 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Button } from "./Button";
3
+ import { Bell } from "lucide-react";
4
+ import { cn } from "../utils";
5
+
6
+ export interface SubscribeButtonProps {
7
+ /**
8
+ * Whether the user is currently subscribed
9
+ */
10
+ isSubscribed: boolean;
11
+ /**
12
+ * Called when user clicks to subscribe
13
+ */
14
+ onSubscribe: () => void;
15
+ /**
16
+ * Called when user clicks to unsubscribe
17
+ */
18
+ onUnsubscribe: () => void;
19
+ /**
20
+ * Show loading state
21
+ */
22
+ loading?: boolean;
23
+ /**
24
+ * Button size variant
25
+ */
26
+ size?: "default" | "sm" | "lg" | "icon";
27
+ /**
28
+ * Additional class names
29
+ */
30
+ className?: string;
31
+ }
32
+
33
+ /**
34
+ * Reusable subscribe/unsubscribe button for notification groups.
35
+ * Shows a bell icon that animates when toggling subscription state.
36
+ */
37
+ export const SubscribeButton: React.FC<SubscribeButtonProps> = ({
38
+ isSubscribed,
39
+ onSubscribe,
40
+ onUnsubscribe,
41
+ loading = false,
42
+ size = "icon",
43
+ className,
44
+ }) => {
45
+ const [animating, setAnimating] = useState(false);
46
+
47
+ // Trigger animation when subscription state changes
48
+ useEffect(() => {
49
+ if (!loading) {
50
+ setAnimating(true);
51
+ const timer = setTimeout(() => setAnimating(false), 500);
52
+ return () => clearTimeout(timer);
53
+ }
54
+ }, [isSubscribed, loading]);
55
+
56
+ const handleClick = () => {
57
+ if (loading) return;
58
+ if (isSubscribed) {
59
+ onUnsubscribe();
60
+ } else {
61
+ onSubscribe();
62
+ }
63
+ };
64
+
65
+ return (
66
+ <Button
67
+ variant={isSubscribed ? "primary" : "ghost"}
68
+ size={size}
69
+ onClick={handleClick}
70
+ disabled={loading}
71
+ className={cn(
72
+ "transition-all duration-200",
73
+ isSubscribed && "text-primary-foreground",
74
+ !isSubscribed && "text-muted-foreground hover:text-foreground",
75
+ className
76
+ )}
77
+ title={
78
+ isSubscribed
79
+ ? "Unsubscribe from notifications"
80
+ : "Subscribe to notifications"
81
+ }
82
+ >
83
+ {loading ? (
84
+ <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
85
+ ) : (
86
+ <Bell
87
+ className={cn(
88
+ "h-4 w-4 transition-all duration-300",
89
+ isSubscribed && "fill-current",
90
+ animating && "animate-bell-ring"
91
+ )}
92
+ />
93
+ )}
94
+ </Button>
95
+ );
96
+ };