@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.
- package/CHANGELOG.md +153 -0
- package/bunfig.toml +2 -0
- package/package.json +40 -0
- package/src/components/Accordion.tsx +55 -0
- package/src/components/Alert.tsx +90 -0
- package/src/components/AmbientBackground.tsx +105 -0
- package/src/components/AnimatedCounter.tsx +54 -0
- package/src/components/BackLink.tsx +56 -0
- package/src/components/Badge.tsx +38 -0
- package/src/components/Button.tsx +55 -0
- package/src/components/Card.tsx +56 -0
- package/src/components/Checkbox.tsx +46 -0
- package/src/components/ColorPicker.tsx +69 -0
- package/src/components/CommandPalette.tsx +74 -0
- package/src/components/ConfirmationModal.tsx +134 -0
- package/src/components/DateRangeFilter.tsx +128 -0
- package/src/components/DateTimePicker.tsx +65 -0
- package/src/components/Dialog.tsx +134 -0
- package/src/components/DropdownMenu.tsx +96 -0
- package/src/components/DynamicForm/DynamicForm.tsx +126 -0
- package/src/components/DynamicForm/DynamicOptionsField.tsx +220 -0
- package/src/components/DynamicForm/FormField.tsx +690 -0
- package/src/components/DynamicForm/JsonField.tsx +98 -0
- package/src/components/DynamicForm/index.ts +11 -0
- package/src/components/DynamicForm/types.ts +95 -0
- package/src/components/DynamicForm/utils.ts +39 -0
- package/src/components/DynamicIcon.tsx +45 -0
- package/src/components/EditableText.tsx +141 -0
- package/src/components/EmptyState.tsx +32 -0
- package/src/components/HealthBadge.tsx +57 -0
- package/src/components/InfoBanner.tsx +97 -0
- package/src/components/Input.tsx +20 -0
- package/src/components/Label.tsx +17 -0
- package/src/components/LoadingSpinner.tsx +29 -0
- package/src/components/Markdown.tsx +206 -0
- package/src/components/NavItem.tsx +112 -0
- package/src/components/Page.tsx +58 -0
- package/src/components/PageLayout.tsx +83 -0
- package/src/components/PaginatedList.tsx +135 -0
- package/src/components/Pagination.tsx +195 -0
- package/src/components/PermissionDenied.tsx +31 -0
- package/src/components/PermissionGate.tsx +97 -0
- package/src/components/PluginConfigForm.tsx +91 -0
- package/src/components/SectionHeader.tsx +30 -0
- package/src/components/Select.tsx +157 -0
- package/src/components/StatusCard.tsx +78 -0
- package/src/components/StatusUpdateTimeline.tsx +222 -0
- package/src/components/StrategyConfigCard.tsx +333 -0
- package/src/components/SubscribeButton.tsx +96 -0
- package/src/components/Table.tsx +119 -0
- package/src/components/Tabs.tsx +141 -0
- package/src/components/TemplateEditor.test.ts +156 -0
- package/src/components/TemplateEditor.tsx +435 -0
- package/src/components/TerminalFeed.tsx +152 -0
- package/src/components/Textarea.tsx +22 -0
- package/src/components/ThemeProvider.tsx +76 -0
- package/src/components/Toast.tsx +118 -0
- package/src/components/ToastProvider.tsx +126 -0
- package/src/components/Toggle.tsx +47 -0
- package/src/components/Tooltip.tsx +20 -0
- package/src/components/UserMenu.tsx +79 -0
- package/src/hooks/usePagination.e2e.ts +275 -0
- package/src/hooks/usePagination.ts +231 -0
- package/src/index.ts +53 -0
- package/src/themes.css +204 -0
- package/src/utils/strip-markdown.ts +44 -0
- package/src/utils.ts +8 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single payload property available for templating
|
|
6
|
+
*/
|
|
7
|
+
export interface TemplateProperty {
|
|
8
|
+
/** Full path to the property, e.g., "payload.incident.title" */
|
|
9
|
+
path: string;
|
|
10
|
+
/** Type of the property, e.g., "string", "number", "boolean" */
|
|
11
|
+
type: string;
|
|
12
|
+
/** Optional description of the property */
|
|
13
|
+
description?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TemplateEditorProps {
|
|
17
|
+
/** Current template value */
|
|
18
|
+
value: string;
|
|
19
|
+
/** Change handler */
|
|
20
|
+
onChange: (value: string) => void;
|
|
21
|
+
/** Available properties for hints */
|
|
22
|
+
availableProperties?: TemplateProperty[];
|
|
23
|
+
/** Placeholder text */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
/** Number of rows */
|
|
26
|
+
rows?: number;
|
|
27
|
+
/** Additional class name */
|
|
28
|
+
className?: string;
|
|
29
|
+
/** Disabled state */
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** Template syntax hint, e.g., "{{" */
|
|
32
|
+
templateSyntax?: "mustache" | "dollar";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get display type color based on property type
|
|
37
|
+
*/
|
|
38
|
+
const getTypeColor = (type: string) => {
|
|
39
|
+
switch (type.toLowerCase()) {
|
|
40
|
+
case "string": {
|
|
41
|
+
return "text-green-600 dark:text-green-400";
|
|
42
|
+
}
|
|
43
|
+
case "number":
|
|
44
|
+
case "integer": {
|
|
45
|
+
return "text-blue-600 dark:text-blue-400";
|
|
46
|
+
}
|
|
47
|
+
case "boolean": {
|
|
48
|
+
return "text-purple-600 dark:text-purple-400";
|
|
49
|
+
}
|
|
50
|
+
case "array": {
|
|
51
|
+
return "text-orange-600 dark:text-orange-400";
|
|
52
|
+
}
|
|
53
|
+
case "object": {
|
|
54
|
+
return "text-yellow-600 dark:text-yellow-400";
|
|
55
|
+
}
|
|
56
|
+
default: {
|
|
57
|
+
return "text-muted-foreground";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detects if the cursor is inside an unclosed template context (after {{ without matching }})
|
|
64
|
+
* Returns the query text being typed and the position where {{ starts
|
|
65
|
+
*/
|
|
66
|
+
export const detectTemplateContext = (
|
|
67
|
+
value: string,
|
|
68
|
+
cursorPos: number,
|
|
69
|
+
syntax: "mustache" | "dollar" = "mustache"
|
|
70
|
+
): { isInTemplate: boolean; query: string; startPos: number } => {
|
|
71
|
+
const openToken = syntax === "mustache" ? "{{" : "${";
|
|
72
|
+
const closeToken = syntax === "mustache" ? "}}" : "}";
|
|
73
|
+
|
|
74
|
+
const textBeforeCursor = value.slice(0, cursorPos);
|
|
75
|
+
const lastOpenBrace = textBeforeCursor.lastIndexOf(openToken);
|
|
76
|
+
const lastCloseBrace = textBeforeCursor.lastIndexOf(closeToken);
|
|
77
|
+
|
|
78
|
+
// If we're after {{ and no }} follows before cursor, we're in a template context
|
|
79
|
+
if (lastOpenBrace !== -1 && lastOpenBrace > lastCloseBrace) {
|
|
80
|
+
const query = textBeforeCursor.slice(lastOpenBrace + openToken.length);
|
|
81
|
+
// Only show popup if query doesn't contain newlines (single line context)
|
|
82
|
+
if (!query.includes("\n")) {
|
|
83
|
+
return { isInTemplate: true, query, startPos: lastOpenBrace };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { isInTemplate: false, query: "", startPos: -1 };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Filter properties based on search query (fuzzy match on path)
|
|
92
|
+
*/
|
|
93
|
+
const filterProperties = (
|
|
94
|
+
properties: TemplateProperty[],
|
|
95
|
+
query: string
|
|
96
|
+
): TemplateProperty[] => {
|
|
97
|
+
if (!query.trim()) return properties;
|
|
98
|
+
const lowerQuery = query.toLowerCase();
|
|
99
|
+
return properties.filter((prop) =>
|
|
100
|
+
prop.path.toLowerCase().includes(lowerQuery)
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* TemplateEditor - A textarea with autocomplete for template properties.
|
|
106
|
+
*
|
|
107
|
+
* Shows an autocomplete popup when the user types "{{" with available
|
|
108
|
+
* template properties. Supports keyboard navigation and filtering.
|
|
109
|
+
*/
|
|
110
|
+
export const TemplateEditor = React.forwardRef<
|
|
111
|
+
HTMLTextAreaElement,
|
|
112
|
+
TemplateEditorProps
|
|
113
|
+
>(
|
|
114
|
+
(
|
|
115
|
+
{
|
|
116
|
+
value,
|
|
117
|
+
onChange,
|
|
118
|
+
availableProperties = [],
|
|
119
|
+
placeholder = "Enter template...",
|
|
120
|
+
rows = 4,
|
|
121
|
+
className,
|
|
122
|
+
disabled = false,
|
|
123
|
+
templateSyntax = "mustache",
|
|
124
|
+
},
|
|
125
|
+
ref
|
|
126
|
+
) => {
|
|
127
|
+
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
|
|
128
|
+
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
129
|
+
const popupRef = React.useRef<HTMLDivElement | null>(null);
|
|
130
|
+
|
|
131
|
+
// Autocomplete state
|
|
132
|
+
const [showPopup, setShowPopup] = React.useState(false);
|
|
133
|
+
const [popupPosition, setPopupPosition] = React.useState({
|
|
134
|
+
top: 0,
|
|
135
|
+
left: 0,
|
|
136
|
+
});
|
|
137
|
+
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
138
|
+
const [templateContext, setTemplateContext] = React.useState<{
|
|
139
|
+
query: string;
|
|
140
|
+
startPos: number;
|
|
141
|
+
}>({ query: "", startPos: -1 });
|
|
142
|
+
|
|
143
|
+
// Filtered properties based on query
|
|
144
|
+
const filteredProperties = React.useMemo(
|
|
145
|
+
() => filterProperties(availableProperties, templateContext.query),
|
|
146
|
+
[availableProperties, templateContext.query]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Merge refs
|
|
150
|
+
React.useImperativeHandle(ref, () => textareaRef.current!);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Calculate popup position based on cursor location in textarea
|
|
154
|
+
*/
|
|
155
|
+
const calculatePopupPosition = React.useCallback(() => {
|
|
156
|
+
const textarea = textareaRef.current;
|
|
157
|
+
const container = containerRef.current;
|
|
158
|
+
if (!textarea || !container) return;
|
|
159
|
+
|
|
160
|
+
// Get textarea position relative to container
|
|
161
|
+
const textareaRect = textarea.getBoundingClientRect();
|
|
162
|
+
const containerRect = container.getBoundingClientRect();
|
|
163
|
+
|
|
164
|
+
// Simple positioning: below the textarea with fixed offset
|
|
165
|
+
// For more accurate cursor-based positioning, we'd need to measure text
|
|
166
|
+
const lineHeight =
|
|
167
|
+
Number.parseInt(getComputedStyle(textarea).lineHeight) || 20;
|
|
168
|
+
const paddingTop =
|
|
169
|
+
Number.parseInt(getComputedStyle(textarea).paddingTop) || 8;
|
|
170
|
+
|
|
171
|
+
// Count newlines before cursor to estimate vertical position
|
|
172
|
+
const cursorPos = textarea.selectionStart ?? 0;
|
|
173
|
+
const textBeforeCursor = value.slice(0, cursorPos);
|
|
174
|
+
const lines = textBeforeCursor.split("\n");
|
|
175
|
+
const currentLineIndex = lines.length - 1;
|
|
176
|
+
|
|
177
|
+
// Position popup below current line
|
|
178
|
+
const top =
|
|
179
|
+
textareaRect.top -
|
|
180
|
+
containerRect.top +
|
|
181
|
+
paddingTop +
|
|
182
|
+
(currentLineIndex + 1) * lineHeight;
|
|
183
|
+
|
|
184
|
+
// Estimate horizontal position based on current line length
|
|
185
|
+
const currentLine = lines[currentLineIndex] ?? "";
|
|
186
|
+
const charWidth = 8; // Approximate monospace char width
|
|
187
|
+
const paddingLeft =
|
|
188
|
+
Number.parseInt(getComputedStyle(textarea).paddingLeft) || 12;
|
|
189
|
+
const left = Math.min(
|
|
190
|
+
paddingLeft + currentLine.length * charWidth,
|
|
191
|
+
textareaRect.width - 200 // Keep popup within textarea bounds
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
setPopupPosition({
|
|
195
|
+
top: Math.max(top, lineHeight),
|
|
196
|
+
left: Math.max(left, 0),
|
|
197
|
+
});
|
|
198
|
+
}, [value]);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Handle text input and detect template context
|
|
202
|
+
*/
|
|
203
|
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
204
|
+
const newValue = e.target.value;
|
|
205
|
+
onChange(newValue);
|
|
206
|
+
|
|
207
|
+
// Check template context after state update
|
|
208
|
+
setTimeout(() => {
|
|
209
|
+
const textarea = textareaRef.current;
|
|
210
|
+
if (!textarea) return;
|
|
211
|
+
|
|
212
|
+
const cursorPos = textarea.selectionStart ?? 0;
|
|
213
|
+
const context = detectTemplateContext(
|
|
214
|
+
newValue,
|
|
215
|
+
cursorPos,
|
|
216
|
+
templateSyntax
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (context.isInTemplate && availableProperties.length > 0) {
|
|
220
|
+
setTemplateContext({
|
|
221
|
+
query: context.query,
|
|
222
|
+
startPos: context.startPos,
|
|
223
|
+
});
|
|
224
|
+
setShowPopup(true);
|
|
225
|
+
setSelectedIndex(0);
|
|
226
|
+
calculatePopupPosition();
|
|
227
|
+
} else {
|
|
228
|
+
setShowPopup(false);
|
|
229
|
+
}
|
|
230
|
+
}, 0);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Handle cursor position changes (click, arrow keys in text)
|
|
235
|
+
*/
|
|
236
|
+
const handleSelect = () => {
|
|
237
|
+
const textarea = textareaRef.current;
|
|
238
|
+
if (!textarea || disabled) return;
|
|
239
|
+
|
|
240
|
+
const cursorPos = textarea.selectionStart ?? 0;
|
|
241
|
+
const context = detectTemplateContext(value, cursorPos, templateSyntax);
|
|
242
|
+
|
|
243
|
+
if (context.isInTemplate && availableProperties.length > 0) {
|
|
244
|
+
setTemplateContext({
|
|
245
|
+
query: context.query,
|
|
246
|
+
startPos: context.startPos,
|
|
247
|
+
});
|
|
248
|
+
setShowPopup(true);
|
|
249
|
+
calculatePopupPosition();
|
|
250
|
+
} else {
|
|
251
|
+
setShowPopup(false);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Insert selected property at the template context position
|
|
257
|
+
*/
|
|
258
|
+
const insertProperty = React.useCallback(
|
|
259
|
+
(prop: TemplateProperty) => {
|
|
260
|
+
const textarea = textareaRef.current;
|
|
261
|
+
if (!textarea || disabled || templateContext.startPos === -1) return;
|
|
262
|
+
|
|
263
|
+
const openToken = templateSyntax === "mustache" ? "{{" : "${";
|
|
264
|
+
const closeToken = templateSyntax === "mustache" ? "}}" : "}";
|
|
265
|
+
const template = `${openToken}${prop.path}${closeToken}`;
|
|
266
|
+
|
|
267
|
+
// Replace from {{ to cursor with the complete template
|
|
268
|
+
const cursorPos = textarea.selectionStart ?? 0;
|
|
269
|
+
const newValue =
|
|
270
|
+
value.slice(0, templateContext.startPos) +
|
|
271
|
+
template +
|
|
272
|
+
value.slice(cursorPos);
|
|
273
|
+
|
|
274
|
+
onChange(newValue);
|
|
275
|
+
setShowPopup(false);
|
|
276
|
+
|
|
277
|
+
// Restore focus and move cursor after inserted text
|
|
278
|
+
const newPosition = templateContext.startPos + template.length;
|
|
279
|
+
setTimeout(() => {
|
|
280
|
+
textarea.focus();
|
|
281
|
+
textarea.setSelectionRange(newPosition, newPosition);
|
|
282
|
+
}, 0);
|
|
283
|
+
},
|
|
284
|
+
[value, onChange, templateContext.startPos, templateSyntax, disabled]
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Handle keyboard navigation in popup
|
|
289
|
+
*/
|
|
290
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
291
|
+
if (!showPopup || filteredProperties.length === 0) return;
|
|
292
|
+
|
|
293
|
+
switch (e.key) {
|
|
294
|
+
case "ArrowDown": {
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
setSelectedIndex((prev) =>
|
|
297
|
+
prev < filteredProperties.length - 1 ? prev + 1 : 0
|
|
298
|
+
);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "ArrowUp": {
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
setSelectedIndex((prev) =>
|
|
304
|
+
prev > 0 ? prev - 1 : filteredProperties.length - 1
|
|
305
|
+
);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case "Enter":
|
|
309
|
+
case "Tab": {
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
const selected = filteredProperties[selectedIndex];
|
|
312
|
+
if (selected) {
|
|
313
|
+
insertProperty(selected);
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "Escape": {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
setShowPopup(false);
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Close popup when clicking outside
|
|
327
|
+
*/
|
|
328
|
+
React.useEffect(() => {
|
|
329
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
330
|
+
if (
|
|
331
|
+
popupRef.current &&
|
|
332
|
+
!popupRef.current.contains(event.target as Node) &&
|
|
333
|
+
textareaRef.current &&
|
|
334
|
+
!textareaRef.current.contains(event.target as Node)
|
|
335
|
+
) {
|
|
336
|
+
setShowPopup(false);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
if (showPopup) {
|
|
341
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
342
|
+
return () =>
|
|
343
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
344
|
+
}
|
|
345
|
+
}, [showPopup]);
|
|
346
|
+
|
|
347
|
+
// Reset selected index when filtered properties change
|
|
348
|
+
React.useEffect(() => {
|
|
349
|
+
setSelectedIndex(0);
|
|
350
|
+
}, [filteredProperties.length]);
|
|
351
|
+
|
|
352
|
+
// Scroll selected item into view
|
|
353
|
+
React.useEffect(() => {
|
|
354
|
+
if (!showPopup || !popupRef.current) return;
|
|
355
|
+
const selectedElement = popupRef.current.querySelector(
|
|
356
|
+
`[data-index="${selectedIndex}"]`
|
|
357
|
+
);
|
|
358
|
+
selectedElement?.scrollIntoView({ block: "nearest" });
|
|
359
|
+
}, [selectedIndex, showPopup]);
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<div ref={containerRef} className={cn("relative", className)}>
|
|
363
|
+
{/* Textarea */}
|
|
364
|
+
<textarea
|
|
365
|
+
ref={textareaRef}
|
|
366
|
+
value={value}
|
|
367
|
+
onChange={handleChange}
|
|
368
|
+
onSelect={handleSelect}
|
|
369
|
+
onKeyDown={handleKeyDown}
|
|
370
|
+
placeholder={placeholder}
|
|
371
|
+
rows={rows}
|
|
372
|
+
disabled={disabled}
|
|
373
|
+
className={cn(
|
|
374
|
+
"flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono",
|
|
375
|
+
"placeholder:text-muted-foreground",
|
|
376
|
+
"focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
|
|
377
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
378
|
+
"resize-y min-h-[80px]"
|
|
379
|
+
)}
|
|
380
|
+
/>
|
|
381
|
+
|
|
382
|
+
{/* Autocomplete popup */}
|
|
383
|
+
{showPopup && filteredProperties.length > 0 && (
|
|
384
|
+
<div
|
|
385
|
+
ref={popupRef}
|
|
386
|
+
className={cn(
|
|
387
|
+
"absolute z-50 w-72 max-h-48 overflow-y-auto",
|
|
388
|
+
"rounded-md border border-border bg-popover shadow-lg"
|
|
389
|
+
)}
|
|
390
|
+
style={{ top: popupPosition.top, left: popupPosition.left }}
|
|
391
|
+
>
|
|
392
|
+
<div className="p-1">
|
|
393
|
+
{filteredProperties.map((prop, index) => (
|
|
394
|
+
<button
|
|
395
|
+
key={prop.path}
|
|
396
|
+
type="button"
|
|
397
|
+
data-index={index}
|
|
398
|
+
onClick={() => insertProperty(prop)}
|
|
399
|
+
className={cn(
|
|
400
|
+
"w-full flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded-sm text-left",
|
|
401
|
+
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
|
402
|
+
index === selectedIndex &&
|
|
403
|
+
"bg-accent text-accent-foreground"
|
|
404
|
+
)}
|
|
405
|
+
>
|
|
406
|
+
<code className="font-mono truncate">{prop.path}</code>
|
|
407
|
+
<span
|
|
408
|
+
className={cn(
|
|
409
|
+
"font-normal shrink-0",
|
|
410
|
+
getTypeColor(prop.type)
|
|
411
|
+
)}
|
|
412
|
+
>
|
|
413
|
+
{prop.type}
|
|
414
|
+
</span>
|
|
415
|
+
</button>
|
|
416
|
+
))}
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
)}
|
|
420
|
+
|
|
421
|
+
{/* Hint text when no popup shown */}
|
|
422
|
+
{availableProperties.length > 0 && !showPopup && (
|
|
423
|
+
<p className="text-xs text-muted-foreground mt-1.5">
|
|
424
|
+
Type{" "}
|
|
425
|
+
<code className="px-1 py-0.5 bg-muted rounded text-[10px]">
|
|
426
|
+
{templateSyntax === "mustache" ? "{{" : "${"}
|
|
427
|
+
</code>{" "}
|
|
428
|
+
for template suggestions
|
|
429
|
+
</p>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
);
|
|
435
|
+
TemplateEditor.displayName = "TemplateEditor";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import React, { useRef, useState, useEffect } from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
|
|
4
|
+
export interface TerminalEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
timestamp: Date;
|
|
7
|
+
content: string;
|
|
8
|
+
variant?: "success" | "warning" | "error" | "info" | "default";
|
|
9
|
+
suffix?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TerminalFeedProps {
|
|
13
|
+
entries: TerminalEntry[];
|
|
14
|
+
maxEntries?: number;
|
|
15
|
+
/** Maximum height for the terminal content area (e.g., "300px", "50vh") */
|
|
16
|
+
maxHeight?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const variantConfig = {
|
|
22
|
+
success: {
|
|
23
|
+
symbol: "✓",
|
|
24
|
+
textClass: "text-emerald-400",
|
|
25
|
+
},
|
|
26
|
+
warning: {
|
|
27
|
+
symbol: "⚠",
|
|
28
|
+
textClass: "text-amber-400",
|
|
29
|
+
},
|
|
30
|
+
error: {
|
|
31
|
+
symbol: "✗",
|
|
32
|
+
textClass: "text-red-400",
|
|
33
|
+
},
|
|
34
|
+
info: {
|
|
35
|
+
symbol: "●",
|
|
36
|
+
textClass: "text-blue-400",
|
|
37
|
+
},
|
|
38
|
+
default: {
|
|
39
|
+
symbol: "→",
|
|
40
|
+
textClass: "text-gray-400",
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const formatTime = (date: Date): string => {
|
|
45
|
+
return date.toLocaleTimeString("en-US", {
|
|
46
|
+
hour12: false,
|
|
47
|
+
hour: "2-digit",
|
|
48
|
+
minute: "2-digit",
|
|
49
|
+
second: "2-digit",
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* TerminalFeed - Generic CLI-style activity log
|
|
55
|
+
* A generic terminal display component that receives entries from the caller.
|
|
56
|
+
* The component is agnostic to domain-specific logic (health checks, notifications, etc.)
|
|
57
|
+
*
|
|
58
|
+
* Entries are displayed with newest at the bottom (chronological order).
|
|
59
|
+
* Auto-scrolls to bottom on new entries unless user is hovering the terminal.
|
|
60
|
+
*/
|
|
61
|
+
export const TerminalFeed: React.FC<TerminalFeedProps> = ({
|
|
62
|
+
entries,
|
|
63
|
+
maxEntries = 8,
|
|
64
|
+
maxHeight,
|
|
65
|
+
title = "terminal",
|
|
66
|
+
className,
|
|
67
|
+
}) => {
|
|
68
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
69
|
+
const [isHovering, setIsHovering] = useState(false);
|
|
70
|
+
|
|
71
|
+
// Get entries to display, then reverse so newest is at bottom
|
|
72
|
+
const displayEntries = entries.slice(0, maxEntries).toReversed();
|
|
73
|
+
|
|
74
|
+
// Auto-scroll to bottom when entries change (unless user is hovering)
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!isHovering && contentRef.current) {
|
|
77
|
+
contentRef.current.scrollTop = contentRef.current.scrollHeight;
|
|
78
|
+
}
|
|
79
|
+
}, [entries, isHovering]);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className={cn(
|
|
84
|
+
"rounded-lg overflow-hidden border border-border/50",
|
|
85
|
+
"bg-[#0d1117] shadow-xl",
|
|
86
|
+
className
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
{/* Terminal header */}
|
|
90
|
+
<div className="flex items-center gap-2 px-4 py-2 bg-[#161b22] border-b border-border/30">
|
|
91
|
+
{/* Window controls */}
|
|
92
|
+
<div className="flex items-center gap-1.5">
|
|
93
|
+
<div className="w-3 h-3 rounded-full bg-red-500/80" />
|
|
94
|
+
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
|
|
95
|
+
<div className="w-3 h-3 rounded-full bg-green-500/80" />
|
|
96
|
+
</div>
|
|
97
|
+
{/* Title */}
|
|
98
|
+
<span className="ml-2 text-xs text-gray-400 font-mono">$ {title}</span>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{/* Terminal content */}
|
|
102
|
+
<div
|
|
103
|
+
ref={contentRef}
|
|
104
|
+
className="p-4 font-mono text-sm space-y-1 min-h-[200px] overflow-y-auto"
|
|
105
|
+
style={maxHeight ? { maxHeight } : undefined}
|
|
106
|
+
onMouseEnter={() => setIsHovering(true)}
|
|
107
|
+
onMouseLeave={() => setIsHovering(false)}
|
|
108
|
+
>
|
|
109
|
+
{displayEntries.length === 0 ? (
|
|
110
|
+
<div className="text-gray-500 animate-pulse">
|
|
111
|
+
Waiting for events...
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
displayEntries.map((entry) => {
|
|
115
|
+
const config = variantConfig[entry.variant ?? "default"];
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
key={entry.id}
|
|
119
|
+
className="flex flex-wrap items-start gap-x-2 group"
|
|
120
|
+
>
|
|
121
|
+
{/* Timestamp */}
|
|
122
|
+
<span className="text-gray-500 flex-shrink-0">
|
|
123
|
+
[{formatTime(entry.timestamp)}]
|
|
124
|
+
</span>
|
|
125
|
+
{/* Status symbol */}
|
|
126
|
+
<span className={cn("flex-shrink-0", config.textClass)}>
|
|
127
|
+
{config.symbol}
|
|
128
|
+
</span>
|
|
129
|
+
{/* Content - allows wrapping */}
|
|
130
|
+
<span className="text-gray-300 break-words">
|
|
131
|
+
{entry.content}
|
|
132
|
+
</span>
|
|
133
|
+
{/* Optional suffix (e.g., response time) */}
|
|
134
|
+
{entry.suffix && (
|
|
135
|
+
<span className="text-gray-500 text-xs flex-shrink-0 ml-auto">
|
|
136
|
+
{entry.suffix}
|
|
137
|
+
</span>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
})
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{/* Blinking cursor */}
|
|
145
|
+
<div className="flex items-center gap-1 mt-2">
|
|
146
|
+
<span className="text-emerald-400">$</span>
|
|
147
|
+
<span className="w-2 h-4 bg-emerald-400 animate-pulse" />
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
|
|
4
|
+
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
5
|
+
|
|
6
|
+
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
7
|
+
({ className, ...props }, ref) => {
|
|
8
|
+
return (
|
|
9
|
+
<textarea
|
|
10
|
+
className={cn(
|
|
11
|
+
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
|
|
12
|
+
className
|
|
13
|
+
)}
|
|
14
|
+
ref={ref}
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
Textarea.displayName = "Textarea";
|
|
21
|
+
|
|
22
|
+
export { Textarea };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
type Theme = "light" | "dark" | "system";
|
|
4
|
+
|
|
5
|
+
interface ThemeProviderProps {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
defaultTheme?: Theme;
|
|
8
|
+
storageKey?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ThemeProviderState {
|
|
12
|
+
theme: Theme;
|
|
13
|
+
setTheme: (theme: Theme) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const initialState: ThemeProviderState = {
|
|
17
|
+
theme: "system",
|
|
18
|
+
setTheme: () => {
|
|
19
|
+
// Will be implemented by provider
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
|
24
|
+
|
|
25
|
+
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
|
26
|
+
children,
|
|
27
|
+
defaultTheme = "system",
|
|
28
|
+
storageKey = "checkstack-ui-theme",
|
|
29
|
+
...props
|
|
30
|
+
}) => {
|
|
31
|
+
const [theme, setTheme] = useState<Theme>(
|
|
32
|
+
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const root = globalThis.document.documentElement;
|
|
37
|
+
|
|
38
|
+
root.classList.remove("light", "dark");
|
|
39
|
+
|
|
40
|
+
if (theme === "system") {
|
|
41
|
+
const systemTheme = globalThis.matchMedia("(prefers-color-scheme: dark)")
|
|
42
|
+
.matches
|
|
43
|
+
? "dark"
|
|
44
|
+
: "light";
|
|
45
|
+
|
|
46
|
+
root.classList.add(systemTheme);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
root.classList.add(theme);
|
|
51
|
+
}, [theme]);
|
|
52
|
+
|
|
53
|
+
const value = {
|
|
54
|
+
theme,
|
|
55
|
+
setTheme: (newTheme: Theme) => {
|
|
56
|
+
localStorage.setItem(storageKey, newTheme);
|
|
57
|
+
setTheme(newTheme);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<ThemeProviderContext.Provider {...props} value={value}>
|
|
63
|
+
{children}
|
|
64
|
+
</ThemeProviderContext.Provider>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const useTheme = () => {
|
|
69
|
+
const context = useContext(ThemeProviderContext);
|
|
70
|
+
|
|
71
|
+
if (context === undefined) {
|
|
72
|
+
throw new Error("useTheme must be used within a ThemeProvider");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return context;
|
|
76
|
+
};
|