@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,206 @@
|
|
|
1
|
+
import ReactMarkdown from "react-markdown";
|
|
2
|
+
import type { Components } from "react-markdown";
|
|
3
|
+
import { cn } from "../utils";
|
|
4
|
+
|
|
5
|
+
export interface MarkdownProps {
|
|
6
|
+
/** The markdown content to render */
|
|
7
|
+
children: string;
|
|
8
|
+
/** Additional CSS classes for the container */
|
|
9
|
+
className?: string;
|
|
10
|
+
/** Size variant affecting text size */
|
|
11
|
+
size?: "sm" | "base" | "lg";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Styled markdown renderer using Tailwind.
|
|
16
|
+
*
|
|
17
|
+
* Renders markdown content with consistent styling that matches
|
|
18
|
+
* the application's design system.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <Markdown>**Bold** and *italic* text</Markdown>
|
|
23
|
+
* <Markdown size="sm">Small text with [links](url)</Markdown>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function Markdown({
|
|
27
|
+
children,
|
|
28
|
+
className,
|
|
29
|
+
size = "base",
|
|
30
|
+
}: MarkdownProps) {
|
|
31
|
+
const sizeClasses = {
|
|
32
|
+
sm: "text-sm",
|
|
33
|
+
base: "text-base",
|
|
34
|
+
lg: "text-lg",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const components: Components = {
|
|
38
|
+
// Paragraphs - no extra margin for inline use
|
|
39
|
+
p: ({ children }) => <span>{children}</span>,
|
|
40
|
+
|
|
41
|
+
// Bold text
|
|
42
|
+
strong: ({ children }) => (
|
|
43
|
+
<strong className="font-semibold text-foreground">{children}</strong>
|
|
44
|
+
),
|
|
45
|
+
|
|
46
|
+
// Italic text
|
|
47
|
+
em: ({ children }) => <em className="italic">{children}</em>,
|
|
48
|
+
|
|
49
|
+
// Links
|
|
50
|
+
a: ({ href, children }) => (
|
|
51
|
+
<a
|
|
52
|
+
href={href}
|
|
53
|
+
className="text-primary hover:underline"
|
|
54
|
+
target="_blank"
|
|
55
|
+
rel="noopener noreferrer"
|
|
56
|
+
>
|
|
57
|
+
{children}
|
|
58
|
+
</a>
|
|
59
|
+
),
|
|
60
|
+
|
|
61
|
+
// Inline code
|
|
62
|
+
code: ({ children }) => (
|
|
63
|
+
<code className="px-1 py-0.5 rounded bg-muted font-mono text-[0.9em]">
|
|
64
|
+
{children}
|
|
65
|
+
</code>
|
|
66
|
+
),
|
|
67
|
+
|
|
68
|
+
// Strikethrough
|
|
69
|
+
del: ({ children }) => (
|
|
70
|
+
<del className="line-through text-muted-foreground">{children}</del>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<span className={cn(sizeClasses[size], className)}>
|
|
76
|
+
<ReactMarkdown components={components}>{children}</ReactMarkdown>
|
|
77
|
+
</span>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface MarkdownBlockProps extends MarkdownProps {
|
|
82
|
+
/** Whether to show as a prose block with proper spacing */
|
|
83
|
+
prose?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Styled markdown block renderer for longer content.
|
|
88
|
+
*
|
|
89
|
+
* Unlike `Markdown`, this renders full blocks with proper paragraph
|
|
90
|
+
* spacing, headers, lists, and other block-level elements.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```tsx
|
|
94
|
+
* <MarkdownBlock>
|
|
95
|
+
* # Heading
|
|
96
|
+
*
|
|
97
|
+
* Paragraph with **bold** text.
|
|
98
|
+
*
|
|
99
|
+
* - List item 1
|
|
100
|
+
* - List item 2
|
|
101
|
+
* </MarkdownBlock>
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function MarkdownBlock({
|
|
105
|
+
children,
|
|
106
|
+
className,
|
|
107
|
+
size = "base",
|
|
108
|
+
prose = true,
|
|
109
|
+
}: MarkdownBlockProps) {
|
|
110
|
+
const sizeClasses = {
|
|
111
|
+
sm: "text-sm",
|
|
112
|
+
base: "text-base",
|
|
113
|
+
lg: "text-lg",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const components: Components = {
|
|
117
|
+
// Paragraphs with proper spacing
|
|
118
|
+
p: ({ children }) => <p className="mb-4 last:mb-0">{children}</p>,
|
|
119
|
+
|
|
120
|
+
// Headers
|
|
121
|
+
h1: ({ children }) => (
|
|
122
|
+
<h1 className="text-2xl font-bold mb-4 text-foreground">{children}</h1>
|
|
123
|
+
),
|
|
124
|
+
h2: ({ children }) => (
|
|
125
|
+
<h2 className="text-xl font-semibold mb-3 text-foreground">{children}</h2>
|
|
126
|
+
),
|
|
127
|
+
h3: ({ children }) => (
|
|
128
|
+
<h3 className="text-lg font-semibold mb-2 text-foreground">{children}</h3>
|
|
129
|
+
),
|
|
130
|
+
|
|
131
|
+
// Bold text
|
|
132
|
+
strong: ({ children }) => (
|
|
133
|
+
<strong className="font-semibold text-foreground">{children}</strong>
|
|
134
|
+
),
|
|
135
|
+
|
|
136
|
+
// Italic text
|
|
137
|
+
em: ({ children }) => <em className="italic">{children}</em>,
|
|
138
|
+
|
|
139
|
+
// Links
|
|
140
|
+
a: ({ href, children }) => (
|
|
141
|
+
<a
|
|
142
|
+
href={href}
|
|
143
|
+
className="text-primary hover:underline"
|
|
144
|
+
target="_blank"
|
|
145
|
+
rel="noopener noreferrer"
|
|
146
|
+
>
|
|
147
|
+
{children}
|
|
148
|
+
</a>
|
|
149
|
+
),
|
|
150
|
+
|
|
151
|
+
// Lists
|
|
152
|
+
ul: ({ children }) => (
|
|
153
|
+
<ul className="list-disc list-inside mb-4 space-y-1">{children}</ul>
|
|
154
|
+
),
|
|
155
|
+
ol: ({ children }) => (
|
|
156
|
+
<ol className="list-decimal list-inside mb-4 space-y-1">{children}</ol>
|
|
157
|
+
),
|
|
158
|
+
li: ({ children }) => <li>{children}</li>,
|
|
159
|
+
|
|
160
|
+
// Blockquotes
|
|
161
|
+
blockquote: ({ children }) => (
|
|
162
|
+
<blockquote className="border-l-4 border-muted pl-4 italic text-muted-foreground mb-4">
|
|
163
|
+
{children}
|
|
164
|
+
</blockquote>
|
|
165
|
+
),
|
|
166
|
+
|
|
167
|
+
// Code blocks
|
|
168
|
+
pre: ({ children }) => (
|
|
169
|
+
<pre className="bg-muted p-4 rounded-lg overflow-x-auto mb-4 font-mono text-sm">
|
|
170
|
+
{children}
|
|
171
|
+
</pre>
|
|
172
|
+
),
|
|
173
|
+
code: ({ children, className }) => {
|
|
174
|
+
// Check if this is a code block (has language class)
|
|
175
|
+
const isBlock = className?.includes("language-");
|
|
176
|
+
if (isBlock) {
|
|
177
|
+
return <code>{children}</code>;
|
|
178
|
+
}
|
|
179
|
+
return (
|
|
180
|
+
<code className="px-1 py-0.5 rounded bg-muted font-mono text-[0.9em]">
|
|
181
|
+
{children}
|
|
182
|
+
</code>
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
// Horizontal rule
|
|
187
|
+
hr: () => <hr className="border-border my-6" />,
|
|
188
|
+
|
|
189
|
+
// Strikethrough
|
|
190
|
+
del: ({ children }) => (
|
|
191
|
+
<del className="line-through text-muted-foreground">{children}</del>
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div
|
|
197
|
+
className={cn(
|
|
198
|
+
sizeClasses[size],
|
|
199
|
+
prose && "prose prose-neutral dark:prose-invert max-w-none",
|
|
200
|
+
className
|
|
201
|
+
)}
|
|
202
|
+
>
|
|
203
|
+
<ReactMarkdown components={components}>{children}</ReactMarkdown>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { NavLink } from "react-router-dom";
|
|
3
|
+
import { ChevronDown } from "lucide-react";
|
|
4
|
+
import { cn } from "../utils";
|
|
5
|
+
import { useApi, permissionApiRef } from "@checkstack/frontend-api";
|
|
6
|
+
|
|
7
|
+
export interface NavItemProps {
|
|
8
|
+
to?: string;
|
|
9
|
+
label: string;
|
|
10
|
+
icon?: React.ReactNode;
|
|
11
|
+
permission?: string;
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const NavItem: React.FC<NavItemProps> = ({
|
|
17
|
+
to,
|
|
18
|
+
label,
|
|
19
|
+
icon,
|
|
20
|
+
permission,
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
}) => {
|
|
24
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
25
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
|
|
27
|
+
// Always call hooks at top level
|
|
28
|
+
// We assume permissionApi is available if we use it. Safe fallback?
|
|
29
|
+
// ApiProvider guarantees it if registered. App.tsx registers a default.
|
|
30
|
+
const permissionApi = useApi(permissionApiRef);
|
|
31
|
+
const { allowed, loading } = permissionApi.usePermission(permission || "");
|
|
32
|
+
const hasAccess = permission ? allowed : true;
|
|
33
|
+
|
|
34
|
+
// Handle click outside for dropdown
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
37
|
+
if (
|
|
38
|
+
containerRef.current &&
|
|
39
|
+
!containerRef.current.contains(event.target as Node)
|
|
40
|
+
) {
|
|
41
|
+
setIsOpen(false);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
45
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
if (loading || !hasAccess) return <></>;
|
|
49
|
+
|
|
50
|
+
const baseClasses = cn(
|
|
51
|
+
"flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors cursor-pointer",
|
|
52
|
+
"text-foreground hover:bg-accent hover:text-accent-foreground",
|
|
53
|
+
"focus:outline-none focus:ring-2 focus:ring-ring/50",
|
|
54
|
+
className
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const activeClasses = "bg-accent text-accent-foreground";
|
|
58
|
+
|
|
59
|
+
// Dropdown / Parent Item
|
|
60
|
+
if (children) {
|
|
61
|
+
// Check if any child is active to highlight parent
|
|
62
|
+
// This is naive; normally we'd check paths.
|
|
63
|
+
// For now, let's just rely on click state or strict path matching if 'to' is present on parent.
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="relative group" ref={containerRef}>
|
|
67
|
+
<button
|
|
68
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
69
|
+
className={cn(baseClasses, isOpen && activeClasses)}
|
|
70
|
+
aria-expanded={isOpen}
|
|
71
|
+
>
|
|
72
|
+
{icon && <span className="w-4 h-4">{icon}</span>}
|
|
73
|
+
<span>{label}</span>
|
|
74
|
+
<ChevronDown
|
|
75
|
+
className={cn(
|
|
76
|
+
"w-4 h-4 transition-transform",
|
|
77
|
+
isOpen ? "rotate-180" : ""
|
|
78
|
+
)}
|
|
79
|
+
/>
|
|
80
|
+
</button>
|
|
81
|
+
|
|
82
|
+
{isOpen && (
|
|
83
|
+
<div className="absolute left-0 mt-1 w-48 rounded-md shadow-lg bg-popover ring-1 ring-border z-50 animate-in fade-in zoom-in-95 duration-100">
|
|
84
|
+
<div className="py-1 flex flex-col p-1 gap-1">{children}</div>
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Leaf Item (Link)
|
|
92
|
+
if (to) {
|
|
93
|
+
return (
|
|
94
|
+
<NavLink
|
|
95
|
+
to={to}
|
|
96
|
+
className={({ isActive }) => cn(baseClasses, isActive && activeClasses)}
|
|
97
|
+
end
|
|
98
|
+
>
|
|
99
|
+
{icon && <span className="w-4 h-4">{icon}</span>}
|
|
100
|
+
<span>{label}</span>
|
|
101
|
+
</NavLink>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Fallback (just a label?)
|
|
106
|
+
return (
|
|
107
|
+
<div className={baseClasses}>
|
|
108
|
+
{icon && <span className="w-4 h-4">{icon}</span>}
|
|
109
|
+
<span>{label}</span>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
|
|
4
|
+
interface PageProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const Page = React.forwardRef<HTMLDivElement, PageProps>(
|
|
9
|
+
({ className, children, ...props }, ref) => (
|
|
10
|
+
<div
|
|
11
|
+
ref={ref}
|
|
12
|
+
className={cn("flex flex-col w-full h-full", className)}
|
|
13
|
+
{...props}
|
|
14
|
+
>
|
|
15
|
+
{children}
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
);
|
|
19
|
+
Page.displayName = "Page";
|
|
20
|
+
|
|
21
|
+
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
22
|
+
title: string;
|
|
23
|
+
subtitle?: string;
|
|
24
|
+
actions?: React.ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const PageHeader = React.forwardRef<HTMLDivElement, PageHeaderProps>(
|
|
28
|
+
({ className, title, subtitle, actions, ...props }, ref) => (
|
|
29
|
+
<div
|
|
30
|
+
ref={ref}
|
|
31
|
+
className={cn(
|
|
32
|
+
"flex flex-col md:flex-row items-center justify-between p-6 pb-2",
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
>
|
|
37
|
+
<div className="space-y-1">
|
|
38
|
+
<h2 className="text-2xl font-bold tracking-tight">{title}</h2>
|
|
39
|
+
{subtitle && <p className="text-muted-foreground">{subtitle}</p>}
|
|
40
|
+
</div>
|
|
41
|
+
{actions && <div className="flex items-center space-x-2">{actions}</div>}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
PageHeader.displayName = "PageHeader";
|
|
46
|
+
|
|
47
|
+
interface PageContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
48
|
+
children: React.ReactNode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const PageContent = React.forwardRef<HTMLDivElement, PageContentProps>(
|
|
52
|
+
({ className, children, ...props }, ref) => (
|
|
53
|
+
<div ref={ref} className={cn("flex-1 p-6 pt-2", className)} {...props}>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
PageContent.displayName = "PageContent";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Page,
|
|
4
|
+
PageHeader,
|
|
5
|
+
PageContent,
|
|
6
|
+
LoadingSpinner,
|
|
7
|
+
PermissionDenied,
|
|
8
|
+
} from "..";
|
|
9
|
+
|
|
10
|
+
interface PageLayoutProps {
|
|
11
|
+
title: string;
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
actions?: React.ReactNode;
|
|
14
|
+
loading?: boolean;
|
|
15
|
+
allowed?: boolean;
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
maxWidth?:
|
|
18
|
+
| "sm"
|
|
19
|
+
| "md"
|
|
20
|
+
| "lg"
|
|
21
|
+
| "xl"
|
|
22
|
+
| "2xl"
|
|
23
|
+
| "3xl"
|
|
24
|
+
| "4xl"
|
|
25
|
+
| "5xl"
|
|
26
|
+
| "6xl"
|
|
27
|
+
| "7xl"
|
|
28
|
+
| "full";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const PageLayout: React.FC<PageLayoutProps> = ({
|
|
32
|
+
title,
|
|
33
|
+
subtitle,
|
|
34
|
+
actions,
|
|
35
|
+
loading,
|
|
36
|
+
allowed,
|
|
37
|
+
children,
|
|
38
|
+
maxWidth = "3xl",
|
|
39
|
+
}) => {
|
|
40
|
+
// If loading is explicitly true, show loading state
|
|
41
|
+
// If loading is undefined and allowed is false, also show loading state
|
|
42
|
+
// (this prevents "Access Denied" flash when permissions are still being fetched)
|
|
43
|
+
const isLoading =
|
|
44
|
+
loading === true || (loading === undefined && allowed === false);
|
|
45
|
+
|
|
46
|
+
if (isLoading) {
|
|
47
|
+
return (
|
|
48
|
+
<Page>
|
|
49
|
+
<PageHeader title={title} subtitle={subtitle} actions={actions} />
|
|
50
|
+
<PageContent>
|
|
51
|
+
<div className="flex justify-center py-12">
|
|
52
|
+
<LoadingSpinner />
|
|
53
|
+
</div>
|
|
54
|
+
</PageContent>
|
|
55
|
+
</Page>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Only show permission denied when loading is explicitly false and allowed is false
|
|
60
|
+
if (allowed === false) {
|
|
61
|
+
return (
|
|
62
|
+
<Page>
|
|
63
|
+
<PageHeader title={title} subtitle={subtitle} actions={actions} />
|
|
64
|
+
<PageContent>
|
|
65
|
+
<PermissionDenied />
|
|
66
|
+
</PageContent>
|
|
67
|
+
</Page>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Page>
|
|
73
|
+
<PageHeader title={title} subtitle={subtitle} actions={actions} />
|
|
74
|
+
<PageContent>
|
|
75
|
+
<div
|
|
76
|
+
className={maxWidth === "full" ? "" : `max-w-${maxWidth} space-y-6`}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</div>
|
|
80
|
+
</PageContent>
|
|
81
|
+
</Page>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
usePagination,
|
|
4
|
+
type UsePaginationOptions,
|
|
5
|
+
type PaginationState,
|
|
6
|
+
} from "../hooks/usePagination";
|
|
7
|
+
import { Pagination } from "./Pagination";
|
|
8
|
+
import { cn } from "../utils";
|
|
9
|
+
|
|
10
|
+
export interface PaginatedListProps<TResponse, TItem, TExtraParams = object>
|
|
11
|
+
extends UsePaginationOptions<TResponse, TItem, TExtraParams> {
|
|
12
|
+
/**
|
|
13
|
+
* Render function for the items
|
|
14
|
+
*/
|
|
15
|
+
children: (
|
|
16
|
+
items: TItem[],
|
|
17
|
+
loading: boolean,
|
|
18
|
+
pagination: PaginationState
|
|
19
|
+
) => React.ReactNode;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Show loading spinner
|
|
23
|
+
* @default true
|
|
24
|
+
*/
|
|
25
|
+
showLoadingSpinner?: boolean;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Content to show when no items
|
|
29
|
+
*/
|
|
30
|
+
emptyContent?: React.ReactNode;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Show pagination controls
|
|
34
|
+
* @default true
|
|
35
|
+
*/
|
|
36
|
+
showPagination?: boolean;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Show page size selector
|
|
40
|
+
* @default true
|
|
41
|
+
*/
|
|
42
|
+
showPageSize?: boolean;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Show total items count
|
|
46
|
+
* @default true
|
|
47
|
+
*/
|
|
48
|
+
showTotal?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Available page sizes
|
|
52
|
+
*/
|
|
53
|
+
pageSizes?: number[];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Container class name
|
|
57
|
+
*/
|
|
58
|
+
className?: string;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pagination container class name
|
|
62
|
+
*/
|
|
63
|
+
paginationClassName?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* All-in-one paginated list component with automatic data fetching.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```tsx
|
|
71
|
+
* <PaginatedList
|
|
72
|
+
* fetchFn={(p) => client.getNotifications(p)}
|
|
73
|
+
* getItems={(r) => r.notifications}
|
|
74
|
+
* getTotal={(r) => r.total}
|
|
75
|
+
* extraParams={{ unreadOnly: true }}
|
|
76
|
+
* >
|
|
77
|
+
* {(items, loading) =>
|
|
78
|
+
* loading ? null : items.map((item) => <Card key={item.id} {...item} />)
|
|
79
|
+
* }
|
|
80
|
+
* </PaginatedList>
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export function PaginatedList<TResponse, TItem, TExtraParams = object>({
|
|
84
|
+
children,
|
|
85
|
+
showLoadingSpinner = true,
|
|
86
|
+
emptyContent,
|
|
87
|
+
showPagination = true,
|
|
88
|
+
showPageSize = true,
|
|
89
|
+
showTotal = true,
|
|
90
|
+
pageSizes,
|
|
91
|
+
className,
|
|
92
|
+
paginationClassName,
|
|
93
|
+
...paginationOptions
|
|
94
|
+
}: PaginatedListProps<TResponse, TItem, TExtraParams>) {
|
|
95
|
+
const { items, loading, pagination } = usePagination(paginationOptions);
|
|
96
|
+
|
|
97
|
+
const showEmpty = !loading && items.length === 0 && emptyContent;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className={cn("space-y-4", className)}>
|
|
101
|
+
{/* Loading state */}
|
|
102
|
+
{loading && showLoadingSpinner && (
|
|
103
|
+
<div className="flex justify-center py-8 text-muted-foreground">
|
|
104
|
+
Loading...
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{/* Empty state */}
|
|
109
|
+
{showEmpty && (
|
|
110
|
+
<div className="flex justify-center py-8 text-muted-foreground">
|
|
111
|
+
{emptyContent}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{/* Content */}
|
|
116
|
+
{!showEmpty && children(items, loading, pagination)}
|
|
117
|
+
|
|
118
|
+
{/* Pagination controls */}
|
|
119
|
+
{showPagination && pagination.totalPages > 1 && (
|
|
120
|
+
<Pagination
|
|
121
|
+
page={pagination.page}
|
|
122
|
+
totalPages={pagination.totalPages}
|
|
123
|
+
onPageChange={pagination.setPage}
|
|
124
|
+
limit={pagination.limit}
|
|
125
|
+
onPageSizeChange={pagination.setLimit}
|
|
126
|
+
total={showTotal ? pagination.total : undefined}
|
|
127
|
+
showPageSize={showPageSize}
|
|
128
|
+
showTotal={showTotal}
|
|
129
|
+
pageSizes={pageSizes}
|
|
130
|
+
className={paginationClassName}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|