@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @checkmate-monitor/ui
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ffc28f6: ### Anonymous Role and Public Access
|
|
8
|
+
|
|
9
|
+
Introduces a configurable "anonymous" role for managing permissions available to unauthenticated users.
|
|
10
|
+
|
|
11
|
+
**Core Changes:**
|
|
12
|
+
|
|
13
|
+
- Added `userType: "public"` - endpoints accessible by both authenticated users (with their permissions) and anonymous users (with anonymous role permissions)
|
|
14
|
+
- Renamed `userType: "both"` to `"authenticated"` for clarity
|
|
15
|
+
- Renamed `isDefault` to `isAuthenticatedDefault` on Permission interface
|
|
16
|
+
- Added `isPublicDefault` flag for permissions that should be granted to the anonymous role by default
|
|
17
|
+
|
|
18
|
+
**Backend Infrastructure:**
|
|
19
|
+
|
|
20
|
+
- New `anonymous` system role created during auth-backend initialization
|
|
21
|
+
- New `disabled_public_default_permission` table tracks admin-disabled public defaults
|
|
22
|
+
- `autoAuthMiddleware` now checks anonymous role permissions for unauthenticated public endpoint access
|
|
23
|
+
- `AuthService.getAnonymousPermissions()` with 1-minute caching for performance
|
|
24
|
+
- Anonymous role filtered from `getRoles` endpoint (not assignable to users)
|
|
25
|
+
- Validation prevents assigning anonymous role to users
|
|
26
|
+
|
|
27
|
+
**Catalog Integration:**
|
|
28
|
+
|
|
29
|
+
- `catalog.read` permission now has both `isAuthenticatedDefault` and `isPublicDefault`
|
|
30
|
+
- Read endpoints (`getSystems`, `getGroups`, `getEntities`) now use `userType: "public"`
|
|
31
|
+
|
|
32
|
+
**UI:**
|
|
33
|
+
|
|
34
|
+
- New `PermissionGate` component for conditionally rendering content based on permissions
|
|
35
|
+
|
|
36
|
+
- b354ab3: # Strategy Instructions Support & Telegram Notification Plugin
|
|
37
|
+
|
|
38
|
+
## Strategy Instructions Interface
|
|
39
|
+
|
|
40
|
+
Added `adminInstructions` and `userInstructions` optional fields to the `NotificationStrategy` interface. These allow strategies to export markdown-formatted setup guides that are displayed in the configuration UI:
|
|
41
|
+
|
|
42
|
+
- **`adminInstructions`**: Shown when admins configure platform-wide strategy settings (e.g., how to create API keys)
|
|
43
|
+
- **`userInstructions`**: Shown when users configure their personal settings (e.g., how to link their account)
|
|
44
|
+
|
|
45
|
+
### Updated Components
|
|
46
|
+
|
|
47
|
+
- `StrategyConfigCard` now accepts an `instructions` prop and renders it before config sections
|
|
48
|
+
- `StrategyCard` passes `adminInstructions` to `StrategyConfigCard`
|
|
49
|
+
- `UserChannelCard` renders `userInstructions` when users need to connect
|
|
50
|
+
|
|
51
|
+
## New Telegram Notification Plugin
|
|
52
|
+
|
|
53
|
+
Added `@checkmate-monitor/notification-telegram-backend` plugin for sending notifications via Telegram:
|
|
54
|
+
|
|
55
|
+
- Uses [grammY](https://grammy.dev/) framework for Telegram Bot API integration
|
|
56
|
+
- Sends messages with MarkdownV2 formatting and inline keyboard buttons for actions
|
|
57
|
+
- Includes comprehensive admin instructions for bot setup via @BotFather
|
|
58
|
+
- Includes user instructions for account linking
|
|
59
|
+
|
|
60
|
+
### Configuration
|
|
61
|
+
|
|
62
|
+
Admins need to configure a Telegram Bot Token obtained from @BotFather.
|
|
63
|
+
|
|
64
|
+
### User Linking
|
|
65
|
+
|
|
66
|
+
The strategy uses `contactResolution: { type: "custom" }` for Telegram Login Widget integration. Full frontend integration for the Login Widget is pending future work.
|
|
67
|
+
|
|
68
|
+
### Patch Changes
|
|
69
|
+
|
|
70
|
+
- eff5b4e: Add standalone maintenance scheduling plugin
|
|
71
|
+
|
|
72
|
+
- New `@checkmate-monitor/maintenance-common` package with Zod schemas, permissions, oRPC contract, and extension slots
|
|
73
|
+
- New `@checkmate-monitor/maintenance-backend` package with Drizzle schema, service, and oRPC router
|
|
74
|
+
- New `@checkmate-monitor/maintenance-frontend` package with admin page and system detail panel
|
|
75
|
+
- Shared `DateTimePicker` component added to `@checkmate-monitor/ui`
|
|
76
|
+
- Database migrations for maintenances, maintenance_systems, and maintenance_updates tables
|
|
77
|
+
- @checkmate-monitor/frontend-api@0.0.2
|
package/bunfig.toml
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkmate-monitor/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@checkmate-monitor/frontend-api": "workspace:*",
|
|
8
|
+
"@radix-ui/react-accordion": "^1.2.12",
|
|
9
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
10
|
+
"@radix-ui/react-select": "^2.2.6",
|
|
11
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
12
|
+
"ajv": "^8.17.1",
|
|
13
|
+
"ajv-formats": "^3.0.1",
|
|
14
|
+
"class-variance-authority": "^0.7.1",
|
|
15
|
+
"clsx": "^2.1.0",
|
|
16
|
+
"date-fns": "^4.1.0",
|
|
17
|
+
"lucide-react": "^0.344.0",
|
|
18
|
+
"prismjs": "^1.29.0",
|
|
19
|
+
"react": "^18.2.0",
|
|
20
|
+
"react-markdown": "^10.1.0",
|
|
21
|
+
"react-router-dom": "^6.20.0",
|
|
22
|
+
"react-simple-code-editor": "^0.14.1",
|
|
23
|
+
"recharts": "^3.6.0",
|
|
24
|
+
"tailwind-merge": "^2.2.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.0.0",
|
|
28
|
+
"@types/react": "^18.2.0",
|
|
29
|
+
"@checkmate-monitor/test-utils-frontend": "workspace:*",
|
|
30
|
+
"@checkmate-monitor/tsconfig": "workspace:*",
|
|
31
|
+
"@checkmate-monitor/scripts": "workspace:*"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"lint": "bun run lint:code",
|
|
36
|
+
"lint:code": "eslint . --max-warnings 0",
|
|
37
|
+
"test": "bun test"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
|
3
|
+
import { ChevronDown } from "lucide-react";
|
|
4
|
+
import { cn } from "../utils";
|
|
5
|
+
|
|
6
|
+
const Accordion = AccordionPrimitive.Root;
|
|
7
|
+
|
|
8
|
+
const AccordionItem = React.forwardRef<
|
|
9
|
+
React.ElementRef<typeof AccordionPrimitive.Item>,
|
|
10
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
11
|
+
>(({ className, ...props }, ref) => (
|
|
12
|
+
<AccordionPrimitive.Item
|
|
13
|
+
ref={ref}
|
|
14
|
+
className={cn("border-b", className)}
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
));
|
|
18
|
+
AccordionItem.displayName = "AccordionItem";
|
|
19
|
+
|
|
20
|
+
const AccordionTrigger = React.forwardRef<
|
|
21
|
+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
|
22
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
23
|
+
>(({ className, children, ...props }, ref) => (
|
|
24
|
+
<AccordionPrimitive.Header className="flex">
|
|
25
|
+
<AccordionPrimitive.Trigger
|
|
26
|
+
ref={ref}
|
|
27
|
+
className={cn(
|
|
28
|
+
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
35
|
+
</AccordionPrimitive.Trigger>
|
|
36
|
+
</AccordionPrimitive.Header>
|
|
37
|
+
));
|
|
38
|
+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
|
39
|
+
|
|
40
|
+
const AccordionContent = React.forwardRef<
|
|
41
|
+
React.ElementRef<typeof AccordionPrimitive.Content>,
|
|
42
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
|
43
|
+
>(({ className, children, ...props }, ref) => (
|
|
44
|
+
<AccordionPrimitive.Content
|
|
45
|
+
ref={ref}
|
|
46
|
+
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
47
|
+
{...props}
|
|
48
|
+
>
|
|
49
|
+
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
|
50
|
+
</AccordionPrimitive.Content>
|
|
51
|
+
));
|
|
52
|
+
|
|
53
|
+
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
|
54
|
+
|
|
55
|
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
const alertVariants = cva("relative w-full rounded-md border p-4", {
|
|
5
|
+
variants: {
|
|
6
|
+
variant: {
|
|
7
|
+
default: "bg-muted/50 border-border text-foreground",
|
|
8
|
+
success: "bg-success/10 border-success/30 text-success-foreground",
|
|
9
|
+
warning: "bg-warning/10 border-warning/30 text-warning-foreground",
|
|
10
|
+
error:
|
|
11
|
+
"bg-destructive/10 border-destructive/30 text-destructive-foreground",
|
|
12
|
+
info: "bg-info/10 border-info/30 text-info-foreground",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: {
|
|
16
|
+
variant: "default",
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export interface AlertProps
|
|
21
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
22
|
+
VariantProps<typeof alertVariants> {
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
|
27
|
+
({ className, variant, children, ...props }, ref) => {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
ref={ref}
|
|
31
|
+
role="alert"
|
|
32
|
+
className={alertVariants({ variant, className })}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
<div className="flex gap-3 items-center">{children}</div>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
Alert.displayName = "Alert";
|
|
42
|
+
|
|
43
|
+
export const AlertIcon = React.forwardRef<
|
|
44
|
+
HTMLDivElement,
|
|
45
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
46
|
+
>(({ className, children, ...props }, ref) => (
|
|
47
|
+
<div
|
|
48
|
+
ref={ref}
|
|
49
|
+
className={`flex-shrink-0 opacity-70 ${className || ""}`}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
));
|
|
55
|
+
|
|
56
|
+
AlertIcon.displayName = "AlertIcon";
|
|
57
|
+
|
|
58
|
+
export const AlertContent = React.forwardRef<
|
|
59
|
+
HTMLDivElement,
|
|
60
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
61
|
+
>(({ className, ...props }, ref) => (
|
|
62
|
+
<div ref={ref} className={`flex-1 space-y-1 ${className || ""}`} {...props} />
|
|
63
|
+
));
|
|
64
|
+
|
|
65
|
+
AlertContent.displayName = "AlertContent";
|
|
66
|
+
|
|
67
|
+
export const AlertTitle = React.forwardRef<
|
|
68
|
+
HTMLHeadingElement,
|
|
69
|
+
React.HTMLAttributes<HTMLHeadingElement>
|
|
70
|
+
>(({ className, ...props }, ref) => (
|
|
71
|
+
<h5
|
|
72
|
+
ref={ref}
|
|
73
|
+
className={`font-semibold text-sm leading-tight ${className || ""}`}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
));
|
|
77
|
+
|
|
78
|
+
AlertTitle.displayName = "AlertTitle";
|
|
79
|
+
|
|
80
|
+
export const AlertDescription = React.forwardRef<
|
|
81
|
+
HTMLParagraphElement,
|
|
82
|
+
React.HTMLAttributes<HTMLParagraphElement>
|
|
83
|
+
>(({ className, ...props }, ref) => (
|
|
84
|
+
<div
|
|
85
|
+
ref={ref}
|
|
86
|
+
className={`text-sm leading-relaxed opacity-90 ${className || ""}`}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
));
|
|
90
|
+
|
|
91
|
+
AlertDescription.displayName = "AlertDescription";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React, { useMemo, useEffect, useState } from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
|
|
4
|
+
interface AmbientBackgroundProps {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const TILE_SIZE = 48;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* AmbientBackground - Animated checkerboard pattern
|
|
13
|
+
* Features a chess-inspired grid where random tiles glow with the primary color.
|
|
14
|
+
* Dynamically adapts to screen size.
|
|
15
|
+
*/
|
|
16
|
+
export const AmbientBackground: React.FC<AmbientBackgroundProps> = ({
|
|
17
|
+
children,
|
|
18
|
+
className,
|
|
19
|
+
}) => {
|
|
20
|
+
const [dimensions, setDimensions] = useState({ cols: 40, rows: 25 });
|
|
21
|
+
|
|
22
|
+
// Calculate grid size based on viewport
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const updateDimensions = () => {
|
|
25
|
+
const cols = Math.ceil(globalThis.innerWidth / TILE_SIZE) + 2;
|
|
26
|
+
const rows = Math.ceil(globalThis.innerHeight / TILE_SIZE) + 2;
|
|
27
|
+
setDimensions({ cols, rows });
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
updateDimensions();
|
|
31
|
+
globalThis.addEventListener("resize", updateDimensions);
|
|
32
|
+
return () => globalThis.removeEventListener("resize", updateDimensions);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
// Generate tile grid with staggered animation delays
|
|
36
|
+
const tiles = useMemo(() => {
|
|
37
|
+
const { cols, rows } = dimensions;
|
|
38
|
+
const result: Array<{ key: string; delay: number; isLight: boolean }> = [];
|
|
39
|
+
|
|
40
|
+
for (let row = 0; row < rows; row++) {
|
|
41
|
+
for (let col = 0; col < cols; col++) {
|
|
42
|
+
const isLight = (row + col) % 2 === 0;
|
|
43
|
+
// Negative delay so tiles start at different points in their 12s cycle
|
|
44
|
+
// This creates a gentle, staggered breathing effect across the grid
|
|
45
|
+
const delay = -((row * 7 + col * 13 + row * col) % 24) * 0.5;
|
|
46
|
+
result.push({
|
|
47
|
+
key: `${row}-${col}`,
|
|
48
|
+
delay,
|
|
49
|
+
isLight,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}, [dimensions]);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={cn(
|
|
59
|
+
"relative min-h-screen bg-background overflow-hidden",
|
|
60
|
+
className
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
{/* Checkerboard grid - covers full viewport */}
|
|
64
|
+
<div
|
|
65
|
+
className="pointer-events-none fixed inset-0 overflow-hidden"
|
|
66
|
+
style={{
|
|
67
|
+
display: "grid",
|
|
68
|
+
gridTemplateColumns: `repeat(${dimensions.cols}, ${TILE_SIZE}px)`,
|
|
69
|
+
gridTemplateRows: `repeat(${dimensions.rows}, ${TILE_SIZE}px)`,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{tiles.map(({ key, delay, isLight }) => (
|
|
73
|
+
<div
|
|
74
|
+
key={key}
|
|
75
|
+
className="tile-glow"
|
|
76
|
+
style={
|
|
77
|
+
{
|
|
78
|
+
width: TILE_SIZE,
|
|
79
|
+
height: TILE_SIZE,
|
|
80
|
+
backgroundColor: isLight
|
|
81
|
+
? "hsl(var(--muted-foreground) / 0.12)"
|
|
82
|
+
: "hsl(var(--muted-foreground) / 0.04)",
|
|
83
|
+
"--glow-delay": `${delay}s`,
|
|
84
|
+
} as React.CSSProperties
|
|
85
|
+
}
|
|
86
|
+
/>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Edge vignette to fade out the grid smoothly */}
|
|
91
|
+
<div
|
|
92
|
+
className="pointer-events-none fixed inset-0"
|
|
93
|
+
style={{
|
|
94
|
+
background: `
|
|
95
|
+
linear-gradient(to right, hsl(var(--background)) 0%, transparent 5%, transparent 95%, hsl(var(--background)) 100%),
|
|
96
|
+
linear-gradient(to bottom, hsl(var(--background)) 0%, transparent 8%, transparent 92%, hsl(var(--background)) 100%)
|
|
97
|
+
`,
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
100
|
+
|
|
101
|
+
{/* Content */}
|
|
102
|
+
<div className="relative z-10">{children}</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React, { useEffect, useState, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
interface AnimatedCounterProps {
|
|
4
|
+
value: number;
|
|
5
|
+
duration?: number;
|
|
6
|
+
formatter?: (n: number) => string;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* AnimatedCounter - Animates a number from 0 to target value
|
|
12
|
+
* Uses requestAnimationFrame for smooth 60fps animation
|
|
13
|
+
*/
|
|
14
|
+
export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({
|
|
15
|
+
value,
|
|
16
|
+
duration = 500,
|
|
17
|
+
formatter = (n) => Math.round(n).toString(),
|
|
18
|
+
className,
|
|
19
|
+
}) => {
|
|
20
|
+
const [displayValue, setDisplayValue] = useState(0);
|
|
21
|
+
const displayValueRef = useRef(displayValue);
|
|
22
|
+
displayValueRef.current = displayValue;
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
// Skip animation if value is 0 or duration is 0
|
|
26
|
+
if (value === 0 || duration === 0) {
|
|
27
|
+
setDisplayValue(value);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const startTime = performance.now();
|
|
32
|
+
const startValue = displayValueRef.current;
|
|
33
|
+
const diff = value - startValue;
|
|
34
|
+
|
|
35
|
+
const animate = (currentTime: number) => {
|
|
36
|
+
const elapsed = currentTime - startTime;
|
|
37
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
38
|
+
|
|
39
|
+
// Ease-out cubic for smooth deceleration
|
|
40
|
+
const easeOut = 1 - Math.pow(1 - progress, 3);
|
|
41
|
+
const current = startValue + diff * easeOut;
|
|
42
|
+
|
|
43
|
+
setDisplayValue(current);
|
|
44
|
+
|
|
45
|
+
if (progress < 1) {
|
|
46
|
+
requestAnimationFrame(animate);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
requestAnimationFrame(animate);
|
|
51
|
+
}, [value, duration]);
|
|
52
|
+
|
|
53
|
+
return <span className={className}>{formatter(displayValue)}</span>;
|
|
54
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ArrowLeft } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface BackLinkProps {
|
|
5
|
+
/** The destination to navigate to. Can be a path string or onClick handler. */
|
|
6
|
+
to?: string;
|
|
7
|
+
/** Click handler for navigation. If not provided with 'to', component will render a button. */
|
|
8
|
+
onClick?: () => void;
|
|
9
|
+
/** The text to display. Defaults to "Back" */
|
|
10
|
+
children?: React.ReactNode;
|
|
11
|
+
/** Optional className for custom styling */
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A standardized back navigation link component.
|
|
17
|
+
* Use with react-router's Link by wrapping it in your own Link component
|
|
18
|
+
* or pass an onClick handler for programmatic navigation.
|
|
19
|
+
*/
|
|
20
|
+
export const BackLink: React.FC<BackLinkProps> = ({
|
|
21
|
+
to,
|
|
22
|
+
onClick,
|
|
23
|
+
children = "Back",
|
|
24
|
+
className = "",
|
|
25
|
+
}) => {
|
|
26
|
+
const baseClasses =
|
|
27
|
+
"flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors";
|
|
28
|
+
|
|
29
|
+
// If 'to' is provided, render an anchor-like element that can be wrapped by Link
|
|
30
|
+
// Otherwise render a button with onClick
|
|
31
|
+
if (to) {
|
|
32
|
+
return (
|
|
33
|
+
<a
|
|
34
|
+
href={to}
|
|
35
|
+
onClick={(e) => {
|
|
36
|
+
// Let react-router handle actual navigation if this is wrapped in Link
|
|
37
|
+
if (onClick) {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
onClick();
|
|
40
|
+
}
|
|
41
|
+
}}
|
|
42
|
+
className={`${baseClasses} ${className}`}
|
|
43
|
+
>
|
|
44
|
+
<ArrowLeft className="h-4 w-4" />
|
|
45
|
+
{children}
|
|
46
|
+
</a>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<button onClick={onClick} className={`${baseClasses} ${className}`}>
|
|
52
|
+
<ArrowLeft className="h-4 w-4" />
|
|
53
|
+
{children}
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "../utils";
|
|
4
|
+
|
|
5
|
+
const badgeVariants = cva(
|
|
6
|
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default:
|
|
11
|
+
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
|
12
|
+
secondary:
|
|
13
|
+
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
14
|
+
destructive:
|
|
15
|
+
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
|
16
|
+
outline: "text-foreground",
|
|
17
|
+
success:
|
|
18
|
+
"border-transparent bg-success/10 text-success-foreground hover:bg-success/20",
|
|
19
|
+
warning:
|
|
20
|
+
"border-transparent bg-warning/10 text-warning-foreground hover:bg-warning/20",
|
|
21
|
+
info: "border-transparent bg-info/10 text-info-foreground hover:bg-info/20",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: "default",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export interface BadgeProps
|
|
31
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
32
|
+
VariantProps<typeof badgeVariants> {}
|
|
33
|
+
|
|
34
|
+
export function Badge({ className, variant, ...props }: BadgeProps) {
|
|
35
|
+
return (
|
|
36
|
+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { cn } from "../utils";
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
12
|
+
secondary:
|
|
13
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
14
|
+
outline:
|
|
15
|
+
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
|
16
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
17
|
+
destructive:
|
|
18
|
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
19
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
20
|
+
},
|
|
21
|
+
size: {
|
|
22
|
+
default: "h-10 px-4 py-2",
|
|
23
|
+
sm: "h-9 rounded-md px-3",
|
|
24
|
+
lg: "h-11 rounded-md px-8",
|
|
25
|
+
icon: "h-10 w-10",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
defaultVariants: {
|
|
29
|
+
variant: "primary",
|
|
30
|
+
size: "default",
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export interface ButtonProps
|
|
36
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
37
|
+
VariantProps<typeof buttonVariants> {
|
|
38
|
+
asChild?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
42
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
43
|
+
const Comp = asChild ? Slot : "button";
|
|
44
|
+
return (
|
|
45
|
+
<Comp
|
|
46
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
47
|
+
ref={ref}
|
|
48
|
+
{...props}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
Button.displayName = "Button";
|
|
54
|
+
|
|
55
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
|
|
4
|
+
export const Card = ({
|
|
5
|
+
className,
|
|
6
|
+
...props
|
|
7
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
8
|
+
<div
|
|
9
|
+
className={cn(
|
|
10
|
+
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
|
11
|
+
className
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const CardHeader = ({
|
|
18
|
+
className,
|
|
19
|
+
...props
|
|
20
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
21
|
+
<div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const CardTitle = ({
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: React.HTMLAttributes<HTMLHeadingElement>) => (
|
|
28
|
+
<h3
|
|
29
|
+
className={cn(
|
|
30
|
+
"text-2xl font-semibold leading-none tracking-tight",
|
|
31
|
+
className
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export const CardDescription = ({
|
|
38
|
+
className,
|
|
39
|
+
...props
|
|
40
|
+
}: React.HTMLAttributes<HTMLParagraphElement>) => (
|
|
41
|
+
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
export const CardContent = ({
|
|
45
|
+
className,
|
|
46
|
+
...props
|
|
47
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
48
|
+
<div className={cn("p-6 pt-0", className)} {...props} />
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export const CardFooter = ({
|
|
52
|
+
className,
|
|
53
|
+
...props
|
|
54
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
55
|
+
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
|
56
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Check } from "lucide-react";
|
|
3
|
+
import { cn } from "../utils";
|
|
4
|
+
|
|
5
|
+
interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
6
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const Checkbox: React.FC<CheckboxProps> = ({
|
|
10
|
+
className,
|
|
11
|
+
checked,
|
|
12
|
+
onCheckedChange,
|
|
13
|
+
...props
|
|
14
|
+
}) => {
|
|
15
|
+
// Compute styles to avoid nested ternary
|
|
16
|
+
const getBackgroundStyles = () => {
|
|
17
|
+
if (props.disabled) {
|
|
18
|
+
return "bg-muted border-border cursor-not-allowed";
|
|
19
|
+
}
|
|
20
|
+
if (checked) {
|
|
21
|
+
return "bg-primary border-primary cursor-pointer";
|
|
22
|
+
}
|
|
23
|
+
return "bg-background border-input cursor-pointer";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
className={cn(
|
|
29
|
+
"peer h-4 w-4 shrink-0 rounded-sm border ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex items-center justify-center transition-colors",
|
|
30
|
+
getBackgroundStyles(),
|
|
31
|
+
className
|
|
32
|
+
)}
|
|
33
|
+
onClick={() => !props.disabled && onCheckedChange?.(!checked)}
|
|
34
|
+
>
|
|
35
|
+
{checked && (
|
|
36
|
+
<Check
|
|
37
|
+
className={cn(
|
|
38
|
+
"h-3 w-3",
|
|
39
|
+
props.disabled ? "text-muted-foreground" : "text-primary-foreground"
|
|
40
|
+
)}
|
|
41
|
+
strokeWidth={3}
|
|
42
|
+
/>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
};
|