@checkmate-monitor/ui 0.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/CHANGELOG.md +77 -0
- package/bunfig.toml +2 -0
- package/package.json +39 -0
- package/src/components/Accordion.tsx +55 -0
- package/src/components/Alert.tsx +91 -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 +52 -0
- package/src/components/EditableText.tsx +141 -0
- package/src/components/EmptyState.tsx +32 -0
- package/src/components/HealthBadge.tsx +58 -0
- package/src/components/InfoBanner.tsx +98 -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 +76 -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,69 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
|
|
4
|
+
interface ColorPickerProps {
|
|
5
|
+
id?: string;
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
13
|
+
id,
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
placeholder = "#000000",
|
|
17
|
+
className,
|
|
18
|
+
}) => {
|
|
19
|
+
const colorInputRef = React.useRef<HTMLInputElement>(null);
|
|
20
|
+
|
|
21
|
+
// Normalize hex color (add # if missing, ensure valid format for color input)
|
|
22
|
+
const normalizedValue = React.useMemo(() => {
|
|
23
|
+
if (!value) return "#000000";
|
|
24
|
+
const hex = value.startsWith("#") ? value : `#${value}`;
|
|
25
|
+
// Validate hex format for the color input (must be #RRGGBB)
|
|
26
|
+
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
|
|
27
|
+
return hex;
|
|
28
|
+
}
|
|
29
|
+
// Handle 3-character hex
|
|
30
|
+
if (/^#[0-9A-Fa-f]{3}$/.test(hex)) {
|
|
31
|
+
const r = hex[1];
|
|
32
|
+
const g = hex[2];
|
|
33
|
+
const b = hex[3];
|
|
34
|
+
return `#${r}${r}${g}${g}${b}${b}`;
|
|
35
|
+
}
|
|
36
|
+
return "#000000";
|
|
37
|
+
}, [value]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className={cn("flex items-center gap-2", className)}>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
className="h-10 w-10 shrink-0 rounded-md border border-input overflow-hidden focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 transition-shadow"
|
|
44
|
+
style={{ backgroundColor: normalizedValue }}
|
|
45
|
+
onClick={() => colorInputRef.current?.click()}
|
|
46
|
+
aria-label="Pick color"
|
|
47
|
+
>
|
|
48
|
+
<input
|
|
49
|
+
ref={colorInputRef}
|
|
50
|
+
type="color"
|
|
51
|
+
value={normalizedValue}
|
|
52
|
+
onChange={(e) => onChange(e.target.value)}
|
|
53
|
+
className="sr-only"
|
|
54
|
+
tabIndex={-1}
|
|
55
|
+
/>
|
|
56
|
+
</button>
|
|
57
|
+
<input
|
|
58
|
+
id={id}
|
|
59
|
+
type="text"
|
|
60
|
+
value={value || ""}
|
|
61
|
+
onChange={(e) => onChange(e.target.value)}
|
|
62
|
+
placeholder={placeholder}
|
|
63
|
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
ColorPicker.displayName = "ColorPicker";
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
import { Command } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface CommandPaletteProps {
|
|
6
|
+
onClick?: () => void;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* CommandPalette - Hero search bar with keyboard shortcut hint
|
|
13
|
+
* Displays a prominent search input that triggers a search dialog on click
|
|
14
|
+
*/
|
|
15
|
+
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|
16
|
+
onClick,
|
|
17
|
+
placeholder = "Search systems, incidents, or run commands...",
|
|
18
|
+
className,
|
|
19
|
+
}) => {
|
|
20
|
+
return (
|
|
21
|
+
<button
|
|
22
|
+
onClick={onClick}
|
|
23
|
+
className={cn(
|
|
24
|
+
// Base styles
|
|
25
|
+
"w-full flex items-center gap-3 px-4 py-3 rounded-xl",
|
|
26
|
+
// Glassmorphism effect
|
|
27
|
+
"bg-card/50 backdrop-blur-sm border border-border/50",
|
|
28
|
+
// Glow and shadow
|
|
29
|
+
"shadow-lg shadow-primary/5 hover:shadow-xl hover:shadow-primary/10",
|
|
30
|
+
// Hover state
|
|
31
|
+
"hover:border-primary/30 hover:bg-card/70",
|
|
32
|
+
// Transition
|
|
33
|
+
"transition-all duration-300 ease-out",
|
|
34
|
+
// Focus ring
|
|
35
|
+
"focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background",
|
|
36
|
+
// Cursor
|
|
37
|
+
"cursor-text",
|
|
38
|
+
className
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
{/* Search icon */}
|
|
42
|
+
<svg
|
|
43
|
+
className="w-5 h-5 text-muted-foreground flex-shrink-0"
|
|
44
|
+
fill="none"
|
|
45
|
+
stroke="currentColor"
|
|
46
|
+
viewBox="0 0 24 24"
|
|
47
|
+
>
|
|
48
|
+
<path
|
|
49
|
+
strokeLinecap="round"
|
|
50
|
+
strokeLinejoin="round"
|
|
51
|
+
strokeWidth={2}
|
|
52
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
53
|
+
/>
|
|
54
|
+
</svg>
|
|
55
|
+
|
|
56
|
+
{/* Placeholder text with typewriter effect */}
|
|
57
|
+
<span className="flex-1 text-left text-muted-foreground text-sm font-medium truncate">
|
|
58
|
+
{placeholder}
|
|
59
|
+
</span>
|
|
60
|
+
|
|
61
|
+
{/* Keyboard shortcut badge */}
|
|
62
|
+
<kbd
|
|
63
|
+
className={cn(
|
|
64
|
+
"hidden sm:flex items-center gap-1 px-2 py-1 rounded-md",
|
|
65
|
+
"bg-muted/50 border border-border/50",
|
|
66
|
+
"text-xs text-muted-foreground font-mono"
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
<Command className="w-3 h-3" />
|
|
70
|
+
<span>K</span>
|
|
71
|
+
</kbd>
|
|
72
|
+
</button>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
import { Button } from "./Button";
|
|
4
|
+
import { AlertTriangle, X } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
export interface ConfirmationModalProps {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onConfirm: () => void;
|
|
10
|
+
title: string;
|
|
11
|
+
message: string;
|
|
12
|
+
confirmText?: string;
|
|
13
|
+
cancelText?: string;
|
|
14
|
+
variant?: "danger" | "warning" | "info";
|
|
15
|
+
isLoading?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
|
19
|
+
isOpen,
|
|
20
|
+
onClose,
|
|
21
|
+
onConfirm,
|
|
22
|
+
title,
|
|
23
|
+
message,
|
|
24
|
+
confirmText = "Confirm",
|
|
25
|
+
cancelText = "Cancel",
|
|
26
|
+
variant = "danger",
|
|
27
|
+
isLoading = false,
|
|
28
|
+
}) => {
|
|
29
|
+
if (!isOpen) return;
|
|
30
|
+
|
|
31
|
+
const handleConfirm = () => {
|
|
32
|
+
onConfirm();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
36
|
+
if (e.target === e.currentTarget) {
|
|
37
|
+
onClose();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const variantStyles = {
|
|
42
|
+
danger: {
|
|
43
|
+
icon: "text-destructive",
|
|
44
|
+
iconBg: "bg-destructive/10",
|
|
45
|
+
button:
|
|
46
|
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
47
|
+
},
|
|
48
|
+
warning: {
|
|
49
|
+
icon: "text-warning",
|
|
50
|
+
iconBg: "bg-warning/10",
|
|
51
|
+
button: "bg-warning text-warning-foreground hover:bg-warning/90",
|
|
52
|
+
},
|
|
53
|
+
info: {
|
|
54
|
+
icon: "text-info",
|
|
55
|
+
iconBg: "bg-info/10",
|
|
56
|
+
button: "bg-info text-info-foreground hover:bg-info/90",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const styles = variantStyles[variant];
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
className="fixed inset-0 z-[60] !m-0 flex items-center justify-center bg-black/50 transition-opacity pointer-events-auto"
|
|
65
|
+
onClick={handleBackdropClick}
|
|
66
|
+
>
|
|
67
|
+
<div
|
|
68
|
+
className="bg-background rounded-lg shadow-xl max-w-md w-full mx-4 animate-in fade-in zoom-in duration-200 pointer-events-auto"
|
|
69
|
+
role="dialog"
|
|
70
|
+
aria-modal="true"
|
|
71
|
+
aria-labelledby="modal-title"
|
|
72
|
+
aria-describedby="modal-description"
|
|
73
|
+
onClick={(e) => e.stopPropagation()}
|
|
74
|
+
>
|
|
75
|
+
{/* Header */}
|
|
76
|
+
<div className="flex items-start justify-between p-6 pb-4">
|
|
77
|
+
<div className="flex items-start gap-4">
|
|
78
|
+
<div className={cn("rounded-full p-2", styles.iconBg)}>
|
|
79
|
+
<AlertTriangle className={cn("w-6 h-6", styles.icon)} />
|
|
80
|
+
</div>
|
|
81
|
+
<div className="flex-1">
|
|
82
|
+
<h3
|
|
83
|
+
id="modal-title"
|
|
84
|
+
className="text-lg font-semibold text-foreground"
|
|
85
|
+
>
|
|
86
|
+
{title}
|
|
87
|
+
</h3>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<button
|
|
91
|
+
onClick={onClose}
|
|
92
|
+
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
93
|
+
disabled={isLoading}
|
|
94
|
+
>
|
|
95
|
+
<X className="w-5 h-5" />
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Body */}
|
|
100
|
+
<div className="px-6 pb-6">
|
|
101
|
+
<p
|
|
102
|
+
id="modal-description"
|
|
103
|
+
className="text-sm text-muted-foreground pl-14"
|
|
104
|
+
>
|
|
105
|
+
{message}
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{/* Footer */}
|
|
110
|
+
<div className="flex items-center justify-end gap-3 px-6 py-4 bg-muted/30 rounded-b-lg">
|
|
111
|
+
<Button
|
|
112
|
+
variant="ghost"
|
|
113
|
+
onClick={onClose}
|
|
114
|
+
disabled={isLoading}
|
|
115
|
+
type="button"
|
|
116
|
+
>
|
|
117
|
+
{cancelText}
|
|
118
|
+
</Button>
|
|
119
|
+
<button
|
|
120
|
+
onClick={handleConfirm}
|
|
121
|
+
disabled={isLoading}
|
|
122
|
+
className={cn(
|
|
123
|
+
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none h-10 px-4 py-2",
|
|
124
|
+
styles.button
|
|
125
|
+
)}
|
|
126
|
+
type="button"
|
|
127
|
+
>
|
|
128
|
+
{isLoading ? "Processing..." : confirmText}
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
2
|
+
import { subDays, subHours, startOfDay } from "date-fns";
|
|
3
|
+
import { DateTimePicker } from "./DateTimePicker";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
import { Calendar } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
export interface DateRange {
|
|
8
|
+
startDate: Date;
|
|
9
|
+
endDate: Date;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DateRangePreset = "24h" | "7d" | "30d" | "custom";
|
|
13
|
+
|
|
14
|
+
export interface DateRangeFilterProps {
|
|
15
|
+
value: DateRange;
|
|
16
|
+
onChange: (range: DateRange) => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PRESETS: Array<{ id: DateRangePreset; label: string }> = [
|
|
21
|
+
{ id: "24h", label: "Last 24h" },
|
|
22
|
+
{ id: "7d", label: "Last 7 days" },
|
|
23
|
+
{ id: "30d", label: "Last 30 days" },
|
|
24
|
+
{ id: "custom", label: "Custom" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function getPresetRange(preset: DateRangePreset): DateRange {
|
|
28
|
+
const now = new Date();
|
|
29
|
+
switch (preset) {
|
|
30
|
+
case "24h": {
|
|
31
|
+
return { startDate: subHours(now, 24), endDate: now };
|
|
32
|
+
}
|
|
33
|
+
case "7d": {
|
|
34
|
+
return { startDate: startOfDay(subDays(now, 7)), endDate: now };
|
|
35
|
+
}
|
|
36
|
+
case "30d": {
|
|
37
|
+
return { startDate: startOfDay(subDays(now, 30)), endDate: now };
|
|
38
|
+
}
|
|
39
|
+
case "custom": {
|
|
40
|
+
return { startDate: startOfDay(subDays(now, 7)), endDate: now };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function detectPreset(range: DateRange): DateRangePreset {
|
|
46
|
+
const now = new Date();
|
|
47
|
+
const diffMs = now.getTime() - range.startDate.getTime();
|
|
48
|
+
const diffHours = diffMs / (1000 * 60 * 60);
|
|
49
|
+
const diffDays = diffHours / 24;
|
|
50
|
+
|
|
51
|
+
if (diffHours <= 25 && diffHours >= 23) return "24h";
|
|
52
|
+
if (diffDays <= 8 && diffDays >= 6) return "7d";
|
|
53
|
+
if (diffDays <= 31 && diffDays >= 29) return "30d";
|
|
54
|
+
return "custom";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Date range filter with preset buttons and custom date pickers.
|
|
59
|
+
*/
|
|
60
|
+
export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
|
|
61
|
+
value,
|
|
62
|
+
onChange,
|
|
63
|
+
className,
|
|
64
|
+
}) => {
|
|
65
|
+
const activePreset = useMemo(() => detectPreset(value), [value]);
|
|
66
|
+
const [showCustom, setShowCustom] = useState(activePreset === "custom");
|
|
67
|
+
|
|
68
|
+
const handlePresetClick = (preset: DateRangePreset) => {
|
|
69
|
+
if (preset === "custom") {
|
|
70
|
+
setShowCustom(true);
|
|
71
|
+
} else {
|
|
72
|
+
setShowCustom(false);
|
|
73
|
+
onChange(getPresetRange(preset));
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={`space-y-3 ${className ?? ""}`}>
|
|
79
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
80
|
+
<Calendar className="h-4 w-4 text-muted-foreground" />
|
|
81
|
+
<span className="text-sm font-medium text-muted-foreground">
|
|
82
|
+
Time range:
|
|
83
|
+
</span>
|
|
84
|
+
<div className="flex gap-1">
|
|
85
|
+
{PRESETS.map((preset) => (
|
|
86
|
+
<Button
|
|
87
|
+
key={preset.id}
|
|
88
|
+
variant={
|
|
89
|
+
activePreset === preset.id && !showCustom
|
|
90
|
+
? "primary"
|
|
91
|
+
: preset.id === "custom" && showCustom
|
|
92
|
+
? "primary"
|
|
93
|
+
: "outline"
|
|
94
|
+
}
|
|
95
|
+
size="sm"
|
|
96
|
+
onClick={() => handlePresetClick(preset.id)}
|
|
97
|
+
>
|
|
98
|
+
{preset.label}
|
|
99
|
+
</Button>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{showCustom && (
|
|
105
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
106
|
+
<span className="text-sm text-muted-foreground">From:</span>
|
|
107
|
+
<DateTimePicker
|
|
108
|
+
value={value.startDate}
|
|
109
|
+
onChange={(startDate) => onChange({ ...value, startDate })}
|
|
110
|
+
maxDate={value.endDate}
|
|
111
|
+
/>
|
|
112
|
+
<span className="text-sm text-muted-foreground">To:</span>
|
|
113
|
+
<DateTimePicker
|
|
114
|
+
value={value.endDate}
|
|
115
|
+
onChange={(endDate) => onChange({ ...value, endDate })}
|
|
116
|
+
minDate={value.startDate}
|
|
117
|
+
maxDate={new Date()}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Create a default date range (last 7 days) */
|
|
126
|
+
export function getDefaultDateRange(): DateRange {
|
|
127
|
+
return getPresetRange("7d");
|
|
128
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { format } from "date-fns";
|
|
3
|
+
import { Input } from "./Input";
|
|
4
|
+
import { Calendar, Clock } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
export interface DateTimePickerProps {
|
|
7
|
+
value: Date;
|
|
8
|
+
onChange: (date: Date) => void;
|
|
9
|
+
minDate?: Date;
|
|
10
|
+
maxDate?: Date;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Combined date and time picker component
|
|
16
|
+
*/
|
|
17
|
+
export const DateTimePicker: React.FC<DateTimePickerProps> = ({
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
minDate,
|
|
21
|
+
maxDate,
|
|
22
|
+
className,
|
|
23
|
+
}) => {
|
|
24
|
+
const dateString = format(value, "yyyy-MM-dd");
|
|
25
|
+
const timeString = format(value, "HH:mm");
|
|
26
|
+
|
|
27
|
+
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
28
|
+
const [year, month, day] = e.target.value.split("-").map(Number);
|
|
29
|
+
const newDate = new Date(value);
|
|
30
|
+
newDate.setFullYear(year, month - 1, day);
|
|
31
|
+
onChange(newDate);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
35
|
+
const [hours, minutes] = e.target.value.split(":").map(Number);
|
|
36
|
+
const newDate = new Date(value);
|
|
37
|
+
newDate.setHours(hours, minutes);
|
|
38
|
+
onChange(newDate);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className={`flex gap-2 ${className ?? ""}`}>
|
|
43
|
+
<div className="relative flex-1">
|
|
44
|
+
<Input
|
|
45
|
+
type="date"
|
|
46
|
+
value={dateString}
|
|
47
|
+
onChange={handleDateChange}
|
|
48
|
+
min={minDate ? format(minDate, "yyyy-MM-dd") : undefined}
|
|
49
|
+
max={maxDate ? format(maxDate, "yyyy-MM-dd") : undefined}
|
|
50
|
+
className="pl-9"
|
|
51
|
+
/>
|
|
52
|
+
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
|
53
|
+
</div>
|
|
54
|
+
<div className="relative w-28">
|
|
55
|
+
<Input
|
|
56
|
+
type="time"
|
|
57
|
+
value={timeString}
|
|
58
|
+
onChange={handleTimeChange}
|
|
59
|
+
className="pl-9"
|
|
60
|
+
/>
|
|
61
|
+
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { cn } from "../utils";
|
|
5
|
+
|
|
6
|
+
const Dialog = DialogPrimitive.Root;
|
|
7
|
+
|
|
8
|
+
const DialogTrigger = DialogPrimitive.Trigger;
|
|
9
|
+
|
|
10
|
+
const DialogPortal = DialogPrimitive.Portal;
|
|
11
|
+
|
|
12
|
+
const DialogClose = DialogPrimitive.Close;
|
|
13
|
+
|
|
14
|
+
const DialogOverlay = React.forwardRef<
|
|
15
|
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
16
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
17
|
+
>(({ className, ...props }, ref) => (
|
|
18
|
+
<DialogPrimitive.Overlay
|
|
19
|
+
ref={ref}
|
|
20
|
+
className={cn(
|
|
21
|
+
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
22
|
+
className
|
|
23
|
+
)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
));
|
|
27
|
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
28
|
+
|
|
29
|
+
const dialogContentVariants = cva(
|
|
30
|
+
"fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background text-foreground p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg max-h-[85vh] overflow-y-auto",
|
|
31
|
+
{
|
|
32
|
+
variants: {
|
|
33
|
+
size: {
|
|
34
|
+
sm: "max-w-sm",
|
|
35
|
+
default: "max-w-lg",
|
|
36
|
+
lg: "max-w-2xl",
|
|
37
|
+
xl: "max-w-4xl",
|
|
38
|
+
full: "max-w-[90vw]",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
defaultVariants: {
|
|
42
|
+
size: "default",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
interface DialogContentProps
|
|
48
|
+
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
|
49
|
+
VariantProps<typeof dialogContentVariants> {}
|
|
50
|
+
|
|
51
|
+
const DialogContent = React.forwardRef<
|
|
52
|
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
53
|
+
DialogContentProps
|
|
54
|
+
>(({ className, children, size, ...props }, ref) => (
|
|
55
|
+
<DialogPortal>
|
|
56
|
+
<DialogOverlay />
|
|
57
|
+
<DialogPrimitive.Content
|
|
58
|
+
ref={ref}
|
|
59
|
+
className={cn(dialogContentVariants({ size }), className)}
|
|
60
|
+
{...props}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</DialogPrimitive.Content>
|
|
64
|
+
</DialogPortal>
|
|
65
|
+
));
|
|
66
|
+
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
67
|
+
|
|
68
|
+
const DialogHeader = ({
|
|
69
|
+
className,
|
|
70
|
+
...props
|
|
71
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
72
|
+
<div
|
|
73
|
+
className={cn(
|
|
74
|
+
"flex flex-col space-y-1.5 text-center sm:text-left",
|
|
75
|
+
className
|
|
76
|
+
)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
DialogHeader.displayName = "DialogHeader";
|
|
81
|
+
|
|
82
|
+
const DialogFooter = ({
|
|
83
|
+
className,
|
|
84
|
+
...props
|
|
85
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
86
|
+
<div
|
|
87
|
+
className={cn(
|
|
88
|
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
89
|
+
className
|
|
90
|
+
)}
|
|
91
|
+
{...props}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
DialogFooter.displayName = "DialogFooter";
|
|
95
|
+
|
|
96
|
+
const DialogTitle = React.forwardRef<
|
|
97
|
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
|
98
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
99
|
+
>(({ className, ...props }, ref) => (
|
|
100
|
+
<DialogPrimitive.Title
|
|
101
|
+
ref={ref}
|
|
102
|
+
className={cn(
|
|
103
|
+
"text-lg font-semibold leading-none tracking-tight",
|
|
104
|
+
className
|
|
105
|
+
)}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
108
|
+
));
|
|
109
|
+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
|
110
|
+
|
|
111
|
+
const DialogDescription = React.forwardRef<
|
|
112
|
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
|
113
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
114
|
+
>(({ className, ...props }, ref) => (
|
|
115
|
+
<DialogPrimitive.Description
|
|
116
|
+
ref={ref}
|
|
117
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
118
|
+
{...props}
|
|
119
|
+
/>
|
|
120
|
+
));
|
|
121
|
+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
|
122
|
+
|
|
123
|
+
export {
|
|
124
|
+
Dialog,
|
|
125
|
+
DialogPortal,
|
|
126
|
+
DialogOverlay,
|
|
127
|
+
DialogClose,
|
|
128
|
+
DialogTrigger,
|
|
129
|
+
DialogContent,
|
|
130
|
+
DialogHeader,
|
|
131
|
+
DialogFooter,
|
|
132
|
+
DialogTitle,
|
|
133
|
+
DialogDescription,
|
|
134
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React, { useRef, useEffect } from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
|
|
4
|
+
export const DropdownMenu: React.FC<{ children: React.ReactNode }> = ({
|
|
5
|
+
children,
|
|
6
|
+
}) => {
|
|
7
|
+
return <div className="relative inline-block text-left">{children}</div>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const DropdownMenuTrigger: React.FC<{
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
asChild?: boolean;
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
}> = ({ children, onClick }) => {
|
|
15
|
+
return (
|
|
16
|
+
<div onClick={onClick} className="cursor-pointer">
|
|
17
|
+
{children}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const DropdownMenuContent: React.FC<{
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
isOpen: boolean;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
className?: string;
|
|
27
|
+
}> = ({ children, isOpen, onClose, className }) => {
|
|
28
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
32
|
+
if (
|
|
33
|
+
contentRef.current &&
|
|
34
|
+
!contentRef.current.contains(event.target as Node)
|
|
35
|
+
) {
|
|
36
|
+
onClose();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
if (isOpen) {
|
|
41
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
42
|
+
}
|
|
43
|
+
return () => {
|
|
44
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
45
|
+
};
|
|
46
|
+
}, [isOpen, onClose]);
|
|
47
|
+
|
|
48
|
+
if (!isOpen) return <React.Fragment />;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
ref={contentRef}
|
|
53
|
+
className={cn(
|
|
54
|
+
"absolute right-0 mt-2 w-56 origin-top-right rounded-md bg-popover shadow-lg ring-1 ring-border focus:outline-none z-[100] animate-in fade-in zoom-in-95 duration-100",
|
|
55
|
+
className
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
<div className="py-1" role="none">
|
|
59
|
+
{children}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const DropdownMenuItem: React.FC<{
|
|
66
|
+
children: React.ReactNode;
|
|
67
|
+
onClick?: () => void;
|
|
68
|
+
className?: string;
|
|
69
|
+
icon?: React.ReactNode;
|
|
70
|
+
}> = ({ children, onClick, className, icon }) => {
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
onClick={onClick}
|
|
74
|
+
className={cn(
|
|
75
|
+
"flex items-center w-full px-4 py-2 text-sm text-popover-foreground hover:bg-accent hover:text-accent-foreground transition-colors",
|
|
76
|
+
className
|
|
77
|
+
)}
|
|
78
|
+
role="menuitem"
|
|
79
|
+
>
|
|
80
|
+
{icon && <span className="mr-3 text-muted-foreground">{icon}</span>}
|
|
81
|
+
{children}
|
|
82
|
+
</button>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const DropdownMenuSeparator: React.FC = () => (
|
|
87
|
+
<div className="my-1 h-px bg-border" />
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
export const DropdownMenuLabel: React.FC<{ children: React.ReactNode }> = ({
|
|
91
|
+
children,
|
|
92
|
+
}) => (
|
|
93
|
+
<div className="px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
94
|
+
{children}
|
|
95
|
+
</div>
|
|
96
|
+
);
|