@hilum/ui 3.2.3 → 3.2.4
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/index.d.ts +348 -32
- package/dist/index.js +2511 -676
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/alert-dialog.tsx +19 -6
- package/src/components/callout.tsx +99 -0
- package/src/components/card.tsx +13 -0
- package/src/components/chart.tsx +398 -29
- package/src/components/color-picker.tsx +39 -45
- package/src/components/combobox.tsx +83 -64
- package/src/components/command.tsx +35 -0
- package/src/components/context-menu.tsx +49 -27
- package/src/components/data-transfer-controls.tsx +156 -0
- package/src/components/dialog.tsx +19 -6
- package/src/components/dropdown-menu.tsx +50 -28
- package/src/components/file-dropzone.tsx +171 -0
- package/src/components/help-tooltip.tsx +90 -0
- package/src/components/media-asset-card.tsx +118 -0
- package/src/components/menubar.tsx +52 -30
- package/src/components/popover.tsx +31 -17
- package/src/components/rich-text-editor.tsx +448 -0
- package/src/components/searchable-table.tsx +412 -0
- package/src/components/select.tsx +44 -28
- package/src/components/sidebar.tsx +105 -14
- package/src/components/stat-card.tsx +122 -8
- package/src/components/status-badge.tsx +144 -0
- package/src/components/titled-card.tsx +102 -0
- package/src/components/url-redirect-prompt.tsx +85 -0
- package/src/index.ts +10 -0
- package/src/lib/mobile-popper-sheet.ts +78 -0
|
@@ -5,7 +5,9 @@ import { PanelLeft } from "lucide-react";
|
|
|
5
5
|
import { Slot } from "radix-ui";
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
7
|
import { Button } from "./button";
|
|
8
|
+
import { Input } from "./input";
|
|
8
9
|
import { Separator } from "./separator";
|
|
10
|
+
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./sheet";
|
|
9
11
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
|
|
10
12
|
|
|
11
13
|
/* ------------------------------------------------------------------ */
|
|
@@ -13,8 +15,27 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tool
|
|
|
13
15
|
/* ------------------------------------------------------------------ */
|
|
14
16
|
|
|
15
17
|
const SIDEBAR_COOKIE_NAME = "sidebar:state";
|
|
18
|
+
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
|
16
19
|
const SIDEBAR_WIDTH = "16rem";
|
|
20
|
+
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
|
17
21
|
const SIDEBAR_WIDTH_ICON = "3.5rem";
|
|
22
|
+
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
|
23
|
+
const MOBILE_BREAKPOINT = 768;
|
|
24
|
+
|
|
25
|
+
function useIsMobile() {
|
|
26
|
+
const [isMobile, setIsMobile] = React.useState(false);
|
|
27
|
+
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
30
|
+
const onChange = () => setIsMobile(mediaQuery.matches);
|
|
31
|
+
|
|
32
|
+
onChange();
|
|
33
|
+
mediaQuery.addEventListener("change", onChange);
|
|
34
|
+
return () => mediaQuery.removeEventListener("change", onChange);
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
return isMobile;
|
|
38
|
+
}
|
|
18
39
|
|
|
19
40
|
/* ------------------------------------------------------------------ */
|
|
20
41
|
/* Context */
|
|
@@ -23,7 +44,10 @@ const SIDEBAR_WIDTH_ICON = "3.5rem";
|
|
|
23
44
|
interface SidebarContextValue {
|
|
24
45
|
state: "expanded" | "collapsed";
|
|
25
46
|
open: boolean;
|
|
26
|
-
setOpen:
|
|
47
|
+
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
48
|
+
openMobile: boolean;
|
|
49
|
+
setOpenMobile: React.Dispatch<React.SetStateAction<boolean>>;
|
|
50
|
+
isMobile: boolean;
|
|
27
51
|
toggleSidebar: () => void;
|
|
28
52
|
}
|
|
29
53
|
|
|
@@ -54,32 +78,59 @@ function SidebarProvider({
|
|
|
54
78
|
children,
|
|
55
79
|
...props
|
|
56
80
|
}: SidebarProviderProps) {
|
|
81
|
+
const isMobile = useIsMobile();
|
|
82
|
+
const [openMobile, setOpenMobile] = React.useState(false);
|
|
57
83
|
const [_open, _setOpen] = React.useState(defaultOpen);
|
|
58
84
|
|
|
59
85
|
const open = openProp !== undefined ? openProp : _open;
|
|
60
86
|
|
|
61
87
|
const setOpen = React.useCallback(
|
|
62
|
-
(value: boolean) => {
|
|
88
|
+
(value: React.SetStateAction<boolean>) => {
|
|
89
|
+
const nextOpen = typeof value === "function" ? value(open) : value;
|
|
63
90
|
if (onOpenChange) {
|
|
64
|
-
onOpenChange(
|
|
91
|
+
onOpenChange(nextOpen);
|
|
65
92
|
} else {
|
|
66
|
-
_setOpen(
|
|
93
|
+
_setOpen(nextOpen);
|
|
67
94
|
}
|
|
68
|
-
|
|
69
|
-
document.cookie = `${SIDEBAR_COOKIE_NAME}=${value}; path=/; max-age=${60 * 60 * 24 * 7}`;
|
|
95
|
+
document.cookie = `${SIDEBAR_COOKIE_NAME}=${nextOpen}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
|
70
96
|
},
|
|
71
|
-
[onOpenChange],
|
|
97
|
+
[onOpenChange, open],
|
|
72
98
|
);
|
|
73
99
|
|
|
74
100
|
const toggleSidebar = React.useCallback(() => {
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
if (isMobile) {
|
|
102
|
+
setOpenMobile((currentOpen) => !currentOpen);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setOpen((currentOpen) => !currentOpen);
|
|
107
|
+
}, [isMobile, setOpen]);
|
|
108
|
+
|
|
109
|
+
React.useEffect(() => {
|
|
110
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
111
|
+
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
toggleSidebar();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
118
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
119
|
+
}, [toggleSidebar]);
|
|
77
120
|
|
|
78
121
|
const state = open ? "expanded" : "collapsed";
|
|
79
122
|
|
|
80
123
|
const contextValue = React.useMemo<SidebarContextValue>(
|
|
81
|
-
() => ({
|
|
82
|
-
|
|
124
|
+
() => ({
|
|
125
|
+
state,
|
|
126
|
+
open,
|
|
127
|
+
setOpen,
|
|
128
|
+
openMobile,
|
|
129
|
+
setOpenMobile,
|
|
130
|
+
isMobile,
|
|
131
|
+
toggleSidebar,
|
|
132
|
+
}),
|
|
133
|
+
[state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar],
|
|
83
134
|
);
|
|
84
135
|
|
|
85
136
|
return (
|
|
@@ -92,6 +143,7 @@ function SidebarProvider({
|
|
|
92
143
|
style={
|
|
93
144
|
{
|
|
94
145
|
"--sidebar-width": SIDEBAR_WIDTH,
|
|
146
|
+
"--sidebar-width-mobile": SIDEBAR_WIDTH_MOBILE,
|
|
95
147
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
|
96
148
|
...style,
|
|
97
149
|
} as React.CSSProperties
|
|
@@ -109,7 +161,7 @@ function SidebarProvider({
|
|
|
109
161
|
/* Sidebar */
|
|
110
162
|
/* ------------------------------------------------------------------ */
|
|
111
163
|
|
|
112
|
-
interface SidebarProps extends React.ComponentProps<"
|
|
164
|
+
interface SidebarProps extends React.ComponentProps<"div"> {
|
|
113
165
|
side?: "left" | "right";
|
|
114
166
|
variant?: "sidebar" | "floating" | "inset";
|
|
115
167
|
collapsible?: "offcanvas" | "icon" | "none";
|
|
@@ -123,7 +175,7 @@ function Sidebar({
|
|
|
123
175
|
children,
|
|
124
176
|
...props
|
|
125
177
|
}: SidebarProps) {
|
|
126
|
-
const { state } = useSidebar();
|
|
178
|
+
const { state, isMobile, openMobile, setOpenMobile } = useSidebar();
|
|
127
179
|
|
|
128
180
|
if (collapsible === "none") {
|
|
129
181
|
return (
|
|
@@ -143,6 +195,28 @@ function Sidebar({
|
|
|
143
195
|
);
|
|
144
196
|
}
|
|
145
197
|
|
|
198
|
+
if (isMobile) {
|
|
199
|
+
return (
|
|
200
|
+
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
|
201
|
+
<SheetContent
|
|
202
|
+
data-sidebar="sidebar"
|
|
203
|
+
data-mobile="true"
|
|
204
|
+
side={side}
|
|
205
|
+
className={cn(
|
|
206
|
+
"w-(--sidebar-width-mobile) max-w-[85dvw] bg-card p-0 text-foreground [&>button]:hidden",
|
|
207
|
+
className,
|
|
208
|
+
)}
|
|
209
|
+
>
|
|
210
|
+
<SheetHeader className="sr-only">
|
|
211
|
+
<SheetTitle>Sidebar</SheetTitle>
|
|
212
|
+
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
|
213
|
+
</SheetHeader>
|
|
214
|
+
<div className="flex h-full w-full flex-col">{children}</div>
|
|
215
|
+
</SheetContent>
|
|
216
|
+
</Sheet>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
146
220
|
if (collapsible === "offcanvas") {
|
|
147
221
|
return (
|
|
148
222
|
<aside
|
|
@@ -247,7 +321,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|
|
247
321
|
return (
|
|
248
322
|
<main
|
|
249
323
|
data-sidebar="inset"
|
|
250
|
-
className={cn("relative flex min-h-svh flex-1 flex-col
|
|
324
|
+
className={cn("relative flex min-h-svh flex-1 flex-col overflow-hidden bg-background", className)}
|
|
251
325
|
{...props}
|
|
252
326
|
/>
|
|
253
327
|
);
|
|
@@ -297,6 +371,16 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
|
|
|
297
371
|
);
|
|
298
372
|
}
|
|
299
373
|
|
|
374
|
+
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
|
|
375
|
+
return (
|
|
376
|
+
<Input
|
|
377
|
+
data-sidebar="input"
|
|
378
|
+
className={cn("h-8 w-full bg-background shadow-none", className)}
|
|
379
|
+
{...props}
|
|
380
|
+
/>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
300
384
|
/* ------------------------------------------------------------------ */
|
|
301
385
|
/* Group components */
|
|
302
386
|
/* ------------------------------------------------------------------ */
|
|
@@ -619,6 +703,7 @@ export {
|
|
|
619
703
|
SidebarGroupContent,
|
|
620
704
|
SidebarGroupLabel,
|
|
621
705
|
SidebarHeader,
|
|
706
|
+
SidebarInput,
|
|
622
707
|
SidebarInset,
|
|
623
708
|
SidebarMenu,
|
|
624
709
|
SidebarMenuAction,
|
|
@@ -632,5 +717,11 @@ export {
|
|
|
632
717
|
SidebarRail,
|
|
633
718
|
SidebarSeparator,
|
|
634
719
|
SidebarTrigger,
|
|
720
|
+
SIDEBAR_COOKIE_MAX_AGE,
|
|
721
|
+
SIDEBAR_COOKIE_NAME,
|
|
722
|
+
SIDEBAR_KEYBOARD_SHORTCUT,
|
|
723
|
+
SIDEBAR_WIDTH,
|
|
724
|
+
SIDEBAR_WIDTH_ICON,
|
|
725
|
+
SIDEBAR_WIDTH_MOBILE,
|
|
635
726
|
useSidebar,
|
|
636
727
|
};
|
|
@@ -1,19 +1,52 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
|
2
|
+
import { TrendingUp, TrendingDown, Minus, Plus } from "lucide-react";
|
|
3
3
|
import { cn } from "../lib/utils";
|
|
4
4
|
|
|
5
5
|
interface StatCardProps {
|
|
6
|
-
label
|
|
7
|
-
|
|
6
|
+
label?: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
value?: string | number;
|
|
8
9
|
trend?: {
|
|
9
10
|
value: string;
|
|
10
11
|
direction: "up" | "down" | "neutral";
|
|
11
12
|
};
|
|
12
13
|
icon?: React.ReactNode;
|
|
14
|
+
children?: React.ReactNode;
|
|
15
|
+
actionButtons?: React.ReactNode;
|
|
13
16
|
className?: string;
|
|
17
|
+
containerClassName?: string;
|
|
18
|
+
titleClassName?: string;
|
|
19
|
+
valueClassName?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
interface StatCardGridProps {
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
maxSlots?: number;
|
|
25
|
+
onAddCard?: () => void;
|
|
26
|
+
className?: string;
|
|
27
|
+
showEmptySlots?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface StatCardSlotProps {
|
|
31
|
+
onAddCard?: () => void;
|
|
32
|
+
className?: string;
|
|
33
|
+
label?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function StatCard({
|
|
37
|
+
label,
|
|
38
|
+
title,
|
|
39
|
+
value,
|
|
40
|
+
trend,
|
|
41
|
+
icon,
|
|
42
|
+
children,
|
|
43
|
+
actionButtons,
|
|
44
|
+
className,
|
|
45
|
+
containerClassName,
|
|
46
|
+
titleClassName,
|
|
47
|
+
valueClassName,
|
|
48
|
+
}: StatCardProps) {
|
|
49
|
+
const displayLabel = title ?? label;
|
|
17
50
|
const trendColor = {
|
|
18
51
|
up: "text-muted-foreground bg-brand-secondary/30 rounded-full px-2 py-0.5 w-fit",
|
|
19
52
|
down: "text-destructive",
|
|
@@ -27,16 +60,34 @@ function StatCard({ label, value, trend, icon, className }: StatCardProps) {
|
|
|
27
60
|
};
|
|
28
61
|
|
|
29
62
|
return (
|
|
30
|
-
<div
|
|
63
|
+
<div
|
|
64
|
+
className={cn(
|
|
65
|
+
"min-w-0 rounded-xl border border-border bg-card p-4 shadow-natural sm:p-5",
|
|
66
|
+
containerClassName,
|
|
67
|
+
className,
|
|
68
|
+
)}
|
|
69
|
+
data-slot="stat-card"
|
|
70
|
+
>
|
|
31
71
|
<div className="flex items-start justify-between gap-3">
|
|
32
|
-
|
|
72
|
+
{displayLabel && (
|
|
73
|
+
<p className={cn("label min-w-0 text-muted-foreground", titleClassName)}>
|
|
74
|
+
{displayLabel}
|
|
75
|
+
</p>
|
|
76
|
+
)}
|
|
33
77
|
{icon && (
|
|
34
78
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
|
35
79
|
{icon}
|
|
36
80
|
</div>
|
|
37
81
|
)}
|
|
82
|
+
{actionButtons && <div className="flex shrink-0 items-center gap-2">{actionButtons}</div>}
|
|
38
83
|
</div>
|
|
39
|
-
|
|
84
|
+
{value !== undefined ? (
|
|
85
|
+
<p className={cn("heading-xl mt-2 tabular-nums text-foreground", valueClassName)}>
|
|
86
|
+
{value}
|
|
87
|
+
</p>
|
|
88
|
+
) : children ? (
|
|
89
|
+
<div className="mt-2">{children}</div>
|
|
90
|
+
) : null}
|
|
40
91
|
{trend &&
|
|
41
92
|
(() => {
|
|
42
93
|
const Icon = TrendIcon[trend.direction];
|
|
@@ -53,4 +104,67 @@ function StatCard({ label, value, trend, icon, className }: StatCardProps) {
|
|
|
53
104
|
|
|
54
105
|
StatCard.displayName = "StatCard";
|
|
55
106
|
|
|
56
|
-
|
|
107
|
+
function StatCardSlot({ onAddCard, className, label = "Add card" }: StatCardSlotProps) {
|
|
108
|
+
return (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
className={cn(
|
|
112
|
+
"flex min-h-28 w-full min-w-0 items-center justify-center rounded-xl border border-dashed border-border bg-card p-4 text-muted-foreground shadow-natural",
|
|
113
|
+
"transition-[background-color,border-color,color,scale] duration-150 hover:border-ground-300 hover:bg-muted hover:text-foreground active:scale-[0.96]",
|
|
114
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/30",
|
|
115
|
+
className,
|
|
116
|
+
)}
|
|
117
|
+
onClick={onAddCard}
|
|
118
|
+
>
|
|
119
|
+
<span className="flex flex-col items-center gap-1.5">
|
|
120
|
+
<Plus className="size-4" aria-hidden="true" />
|
|
121
|
+
<span className="caption font-medium">{label}</span>
|
|
122
|
+
</span>
|
|
123
|
+
</button>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
StatCardSlot.displayName = "StatCardSlot";
|
|
128
|
+
|
|
129
|
+
function StatCardGrid({
|
|
130
|
+
children,
|
|
131
|
+
maxSlots = 6,
|
|
132
|
+
onAddCard,
|
|
133
|
+
className,
|
|
134
|
+
showEmptySlots = false,
|
|
135
|
+
}: StatCardGridProps) {
|
|
136
|
+
const childrenArray = React.Children.toArray(children);
|
|
137
|
+
const emptySlots = showEmptySlots ? Math.max(0, maxSlots - childrenArray.length) : 0;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
className={cn(
|
|
142
|
+
"grid min-w-0 grid-cols-2 items-stretch gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6",
|
|
143
|
+
className,
|
|
144
|
+
)}
|
|
145
|
+
data-slot="stat-card-grid"
|
|
146
|
+
>
|
|
147
|
+
{childrenArray.map((child, index) => (
|
|
148
|
+
<div key={`filled-${index}`} className="min-w-0">
|
|
149
|
+
{child}
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
{Array.from({ length: emptySlots }, (_, index) => (
|
|
153
|
+
<div key={`empty-${index}`} className="min-w-0">
|
|
154
|
+
<StatCardSlot {...(onAddCard ? { onAddCard } : {})} />
|
|
155
|
+
</div>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
StatCardGrid.displayName = "StatCardGrid";
|
|
162
|
+
|
|
163
|
+
export {
|
|
164
|
+
StatCard,
|
|
165
|
+
StatCardGrid,
|
|
166
|
+
StatCardSlot,
|
|
167
|
+
type StatCardProps,
|
|
168
|
+
type StatCardGridProps,
|
|
169
|
+
type StatCardSlotProps,
|
|
170
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Badge } from "./badge";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
type StatusBadgeVariant = NonNullable<React.ComponentProps<typeof Badge>["variant"]>;
|
|
8
|
+
|
|
9
|
+
interface StatusBadgeProps extends Omit<
|
|
10
|
+
React.ComponentProps<typeof Badge>,
|
|
11
|
+
"children" | "variant"
|
|
12
|
+
> {
|
|
13
|
+
status?: string | null;
|
|
14
|
+
label?: React.ReactNode;
|
|
15
|
+
variant?: StatusBadgeVariant;
|
|
16
|
+
variantMap?: Record<string, StatusBadgeVariant>;
|
|
17
|
+
labelMap?: Record<string, React.ReactNode>;
|
|
18
|
+
showDot?: boolean;
|
|
19
|
+
dotClassName?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_STATUS_VARIANT: Record<string, StatusBadgeVariant> = {
|
|
23
|
+
active: "success",
|
|
24
|
+
approved: "success",
|
|
25
|
+
complete: "success",
|
|
26
|
+
completed: "success",
|
|
27
|
+
confirmed: "success",
|
|
28
|
+
delivered: "success",
|
|
29
|
+
enabled: "success",
|
|
30
|
+
healthy: "success",
|
|
31
|
+
optimized: "success",
|
|
32
|
+
paid: "success",
|
|
33
|
+
published: "success",
|
|
34
|
+
resolved: "success",
|
|
35
|
+
translated: "success",
|
|
36
|
+
verified: "success",
|
|
37
|
+
|
|
38
|
+
degraded: "warning",
|
|
39
|
+
flagged: "warning",
|
|
40
|
+
held_for_review: "warning",
|
|
41
|
+
pending: "warning",
|
|
42
|
+
processing: "warning",
|
|
43
|
+
retrying: "warning",
|
|
44
|
+
scheduled: "warning",
|
|
45
|
+
trial: "warning",
|
|
46
|
+
warning: "warning",
|
|
47
|
+
|
|
48
|
+
archived: "secondary",
|
|
49
|
+
disabled: "secondary",
|
|
50
|
+
draft: "secondary",
|
|
51
|
+
inactive: "secondary",
|
|
52
|
+
missing: "secondary",
|
|
53
|
+
neutral: "secondary",
|
|
54
|
+
skipped: "secondary",
|
|
55
|
+
|
|
56
|
+
blocked: "destructive",
|
|
57
|
+
cancelled: "destructive",
|
|
58
|
+
critical: "destructive",
|
|
59
|
+
dead_letter: "destructive",
|
|
60
|
+
deleted: "destructive",
|
|
61
|
+
error: "destructive",
|
|
62
|
+
failed: "destructive",
|
|
63
|
+
rejected: "destructive",
|
|
64
|
+
suspended: "destructive",
|
|
65
|
+
unpaid: "destructive",
|
|
66
|
+
outdated: "destructive",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const DEFAULT_DOT_CLASS: Record<StatusBadgeVariant, string> = {
|
|
70
|
+
default: "bg-background",
|
|
71
|
+
secondary: "bg-muted-foreground",
|
|
72
|
+
outline: "bg-muted-foreground",
|
|
73
|
+
brand: "bg-background",
|
|
74
|
+
success: "bg-emerald-500",
|
|
75
|
+
warning: "bg-amber-500",
|
|
76
|
+
destructive: "bg-destructive",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function normalizeStatus(status?: string | null) {
|
|
80
|
+
return String(status ?? "unknown")
|
|
81
|
+
.trim()
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/[\s-]+/g, "_");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function statusLabel(status?: string | null) {
|
|
87
|
+
const normalized = normalizeStatus(status);
|
|
88
|
+
if (normalized === "unknown") return "Unknown";
|
|
89
|
+
|
|
90
|
+
return normalized
|
|
91
|
+
.split("_")
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
94
|
+
.join(" ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function statusBadgeVariantFor(
|
|
98
|
+
status?: string | null,
|
|
99
|
+
variantMap?: Record<string, StatusBadgeVariant>,
|
|
100
|
+
) {
|
|
101
|
+
const normalized = normalizeStatus(status);
|
|
102
|
+
return variantMap?.[normalized] ?? DEFAULT_STATUS_VARIANT[normalized] ?? "outline";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function StatusBadge({
|
|
106
|
+
status,
|
|
107
|
+
label,
|
|
108
|
+
variant,
|
|
109
|
+
variantMap,
|
|
110
|
+
labelMap,
|
|
111
|
+
showDot = false,
|
|
112
|
+
dotClassName,
|
|
113
|
+
className,
|
|
114
|
+
...props
|
|
115
|
+
}: StatusBadgeProps) {
|
|
116
|
+
const normalized = normalizeStatus(status);
|
|
117
|
+
const resolvedVariant = variant ?? statusBadgeVariantFor(normalized, variantMap);
|
|
118
|
+
const resolvedLabel = label ?? labelMap?.[normalized] ?? statusLabel(normalized);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Badge
|
|
122
|
+
variant={resolvedVariant}
|
|
123
|
+
className={cn("max-w-full whitespace-nowrap", showDot && "pl-2", className)}
|
|
124
|
+
{...props}
|
|
125
|
+
>
|
|
126
|
+
{showDot && (
|
|
127
|
+
<span
|
|
128
|
+
className={cn(
|
|
129
|
+
"size-1.5 shrink-0 rounded-full",
|
|
130
|
+
DEFAULT_DOT_CLASS[resolvedVariant],
|
|
131
|
+
dotClassName,
|
|
132
|
+
)}
|
|
133
|
+
aria-hidden="true"
|
|
134
|
+
/>
|
|
135
|
+
)}
|
|
136
|
+
<span className="min-w-0 truncate">{resolvedLabel}</span>
|
|
137
|
+
</Badge>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
StatusBadge.displayName = "StatusBadge";
|
|
142
|
+
|
|
143
|
+
export { StatusBadge, statusBadgeVariantFor, statusLabel };
|
|
144
|
+
export type { StatusBadgeProps, StatusBadgeVariant };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import {
|
|
6
|
+
Card,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle,
|
|
11
|
+
} from "./card";
|
|
12
|
+
import { CardHeading } from "./card-heading";
|
|
13
|
+
|
|
14
|
+
interface TitledCardProps {
|
|
15
|
+
title?: string;
|
|
16
|
+
subtitle?: string;
|
|
17
|
+
children?: React.ReactNode;
|
|
18
|
+
actionButtons?: React.ReactNode;
|
|
19
|
+
icon?: React.ComponentType<{ className?: string }>;
|
|
20
|
+
className?: string;
|
|
21
|
+
contentClassName?: string;
|
|
22
|
+
containerClassName?: string;
|
|
23
|
+
titleClassName?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function TitledCard({
|
|
27
|
+
title,
|
|
28
|
+
subtitle,
|
|
29
|
+
children,
|
|
30
|
+
actionButtons,
|
|
31
|
+
icon: Icon,
|
|
32
|
+
className,
|
|
33
|
+
contentClassName,
|
|
34
|
+
containerClassName,
|
|
35
|
+
titleClassName,
|
|
36
|
+
}: TitledCardProps) {
|
|
37
|
+
const hasHeader = Boolean(title || subtitle || actionButtons);
|
|
38
|
+
const canUseCardHeading = Boolean(title) && !actionButtons && !titleClassName;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Card
|
|
42
|
+
className={cn(
|
|
43
|
+
"min-w-0 overflow-hidden border border-border bg-card shadow-natural",
|
|
44
|
+
"max-sm:rounded-none max-sm:border-0 max-sm:bg-transparent max-sm:shadow-none",
|
|
45
|
+
containerClassName,
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
data-slot="titled-card"
|
|
49
|
+
>
|
|
50
|
+
{canUseCardHeading ? (
|
|
51
|
+
<CardHeading
|
|
52
|
+
title={title!}
|
|
53
|
+
{...(subtitle ? { description: subtitle } : {})}
|
|
54
|
+
className="max-sm:border-b-0 max-sm:px-0 max-sm:pb-3"
|
|
55
|
+
>
|
|
56
|
+
{Icon && <Icon className="size-5 shrink-0 text-muted-foreground" />}
|
|
57
|
+
</CardHeading>
|
|
58
|
+
) : hasHeader ? (
|
|
59
|
+
<CardHeader className="flex flex-col gap-3 border-b border-border p-4 max-sm:border-b-0 max-sm:px-0 max-sm:pb-3 sm:flex-row sm:items-start sm:justify-between sm:p-5">
|
|
60
|
+
<div className="min-w-0 flex-1">
|
|
61
|
+
{title && (
|
|
62
|
+
<CardTitle
|
|
63
|
+
className={cn(
|
|
64
|
+
"flex min-w-0 items-center gap-2 text-base leading-tight sm:text-lg",
|
|
65
|
+
titleClassName,
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{Icon && <Icon className="size-5 shrink-0 text-muted-foreground" />}
|
|
69
|
+
<span className="min-w-0 truncate">{title}</span>
|
|
70
|
+
</CardTitle>
|
|
71
|
+
)}
|
|
72
|
+
{subtitle && (
|
|
73
|
+
<CardDescription className="mt-1 max-w-3xl text-sm leading-5">
|
|
74
|
+
{subtitle}
|
|
75
|
+
</CardDescription>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
{actionButtons && (
|
|
79
|
+
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
|
|
80
|
+
{actionButtons}
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</CardHeader>
|
|
84
|
+
) : null}
|
|
85
|
+
{children && (
|
|
86
|
+
<CardContent
|
|
87
|
+
className={cn(
|
|
88
|
+
hasHeader ? "p-4 sm:p-5" : "p-0 sm:p-5",
|
|
89
|
+
"min-w-0 max-sm:px-0",
|
|
90
|
+
contentClassName,
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
{children}
|
|
94
|
+
</CardContent>
|
|
95
|
+
)}
|
|
96
|
+
</Card>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
TitledCard.displayName = "TitledCard";
|
|
101
|
+
|
|
102
|
+
export { TitledCard, type TitledCardProps };
|