@mnee-ui/ui 0.0.1
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/README.md +92 -0
- package/components/ui/alert.tsx +84 -0
- package/components/ui/badge.tsx +38 -0
- package/components/ui/banner.tsx +73 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +156 -0
- package/components/ui/code-block.tsx +108 -0
- package/components/ui/drawer.tsx +164 -0
- package/components/ui/icons.tsx +96 -0
- package/components/ui/index.ts +14 -0
- package/components/ui/input.tsx +129 -0
- package/components/ui/mnee-ui.css +35 -0
- package/components/ui/modal.tsx +134 -0
- package/components/ui/table.tsx +185 -0
- package/components/ui/toast.tsx +136 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @mnee/ui
|
|
2
|
+
|
|
3
|
+
MNEE Design System — the component library that powers the MNEE merchant portal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @mnee/ui
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Add the design tokens to your project's `globals.css` (Tailwind v4):
|
|
14
|
+
|
|
15
|
+
```css
|
|
16
|
+
@import "tailwindcss";
|
|
17
|
+
|
|
18
|
+
@theme {
|
|
19
|
+
--color-brand: #D97706;
|
|
20
|
+
--color-brand-dark: #B45309;
|
|
21
|
+
--color-success: #15803D;
|
|
22
|
+
--color-success-bg: #DCFCE7;
|
|
23
|
+
--color-success-fg: #14532D;
|
|
24
|
+
--color-warning: #D97706;
|
|
25
|
+
--color-warning-bg: #FEF3C7;
|
|
26
|
+
--color-warning-fg: #92400E;
|
|
27
|
+
--color-error: #B91C1C;
|
|
28
|
+
--color-error-bg: #FEE2E2;
|
|
29
|
+
--color-error-fg: #991B1B;
|
|
30
|
+
--color-info: #1D4ED8;
|
|
31
|
+
--color-info-bg: #DBEAFE;
|
|
32
|
+
--color-info-fg: #1E3A8A;
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { Button, Badge, Card, CardHeader, CardTitle, CardContent } from "@mnee/ui"
|
|
40
|
+
|
|
41
|
+
export function PaymentCard() {
|
|
42
|
+
return (
|
|
43
|
+
<Card>
|
|
44
|
+
<CardHeader>
|
|
45
|
+
<CardTitle>Last payment</CardTitle>
|
|
46
|
+
</CardHeader>
|
|
47
|
+
<CardContent className="flex items-center gap-3">
|
|
48
|
+
<Badge variant="success">Completed</Badge>
|
|
49
|
+
<Button variant="outline" size="sm">View receipt</Button>
|
|
50
|
+
</CardContent>
|
|
51
|
+
</Card>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Components
|
|
57
|
+
|
|
58
|
+
| Component | Description |
|
|
59
|
+
|-----------|-------------|
|
|
60
|
+
| `Button` | primary / secondary / destructive / ghost / outline — sm/md/lg sizes, loading state |
|
|
61
|
+
| `Badge` | success / warning / error / info / default status chips |
|
|
62
|
+
| `Card` | Container with CardHeader, CardTitle, CardDescription, CardContent, CardFooter |
|
|
63
|
+
|
|
64
|
+
## Publishing a new version
|
|
65
|
+
|
|
66
|
+
Tag the commit and push — the GitHub Action in `.github/workflows/publish.yml` handles the rest:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
git tag v0.0.2
|
|
70
|
+
git push origin v0.0.2
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Requires `NPM_TOKEN` set as a repository secret in `github.com/mnee-xyz/ui`.
|
|
74
|
+
|
|
75
|
+
## Docs site
|
|
76
|
+
|
|
77
|
+
Run locally:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm run dev
|
|
81
|
+
# → http://localhost:3000
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Project structure
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
components/ui/ ← the library (@mnee/ui exports)
|
|
88
|
+
components/site/ ← docs site only (not exported)
|
|
89
|
+
app/docs/ ← documentation pages
|
|
90
|
+
app/ ← Next.js App Router
|
|
91
|
+
figma/ ← Figma Code Connect (V2)
|
|
92
|
+
```
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Info, AlertTriangle, Lightbulb, CheckCircle } from "lucide-react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
export type AlertVariant = "info" | "warning" | "tip" | "error" | "success";
|
|
7
|
+
|
|
8
|
+
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
variant?: AlertVariant;
|
|
10
|
+
title?: string;
|
|
11
|
+
children?: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const variantStyles: Record<AlertVariant, { wrapper: string; text: string }> = {
|
|
15
|
+
info: {
|
|
16
|
+
wrapper: "border-blue-800 bg-blue-50",
|
|
17
|
+
text: "text-blue-800",
|
|
18
|
+
},
|
|
19
|
+
warning: {
|
|
20
|
+
wrapper: "border-[#FFF085] bg-[#FEFCE8]",
|
|
21
|
+
text: "text-[#A65F00]",
|
|
22
|
+
},
|
|
23
|
+
tip: {
|
|
24
|
+
wrapper: "border-[#FFF085] bg-[#FEFCE8]",
|
|
25
|
+
text: "text-[#A65F00]",
|
|
26
|
+
},
|
|
27
|
+
error: {
|
|
28
|
+
wrapper: "border-error/40 bg-error-bg",
|
|
29
|
+
text: "text-error",
|
|
30
|
+
},
|
|
31
|
+
success: {
|
|
32
|
+
wrapper: "border-success/40 bg-success-bg",
|
|
33
|
+
text: "text-success",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const variantIcons: Record<AlertVariant, React.ElementType> = {
|
|
38
|
+
info: Info,
|
|
39
|
+
warning: AlertTriangle,
|
|
40
|
+
tip: Lightbulb,
|
|
41
|
+
error: AlertTriangle,
|
|
42
|
+
success: CheckCircle,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const variantLabels: Record<AlertVariant, string> = {
|
|
46
|
+
info: "Note",
|
|
47
|
+
warning: "Warning",
|
|
48
|
+
tip: "Tip",
|
|
49
|
+
error: "Error",
|
|
50
|
+
success: "Success",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function Alert({
|
|
54
|
+
variant = "info",
|
|
55
|
+
title,
|
|
56
|
+
children,
|
|
57
|
+
className,
|
|
58
|
+
...props
|
|
59
|
+
}: AlertProps) {
|
|
60
|
+
const styles = variantStyles[variant];
|
|
61
|
+
const Icon = variantIcons[variant];
|
|
62
|
+
const label = title ?? variantLabels[variant];
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
className={cn(
|
|
67
|
+
"rounded-lg border px-4 py-2",
|
|
68
|
+
styles.wrapper,
|
|
69
|
+
className
|
|
70
|
+
)}
|
|
71
|
+
{...props}
|
|
72
|
+
>
|
|
73
|
+
<div className={cn("flex items-center gap-2 font-medium text-[12px]", styles.text)}>
|
|
74
|
+
<Icon size={15} />
|
|
75
|
+
<span>{label}</span>
|
|
76
|
+
</div>
|
|
77
|
+
{children && (
|
|
78
|
+
<div className={cn("pl-6 font-light text-[12px]", styles.text)}>
|
|
79
|
+
{children}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
|
|
3
|
+
export type BadgeVariant = "success" | "warning" | "error" | "info" | "default" | "brand";
|
|
4
|
+
|
|
5
|
+
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
6
|
+
variant?: BadgeVariant;
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const variantStyles: Record<BadgeVariant, string> = {
|
|
11
|
+
success: "bg-success text-white",
|
|
12
|
+
warning: "bg-warning text-white",
|
|
13
|
+
error: "bg-red-600 text-white",
|
|
14
|
+
info: "bg-info text-white",
|
|
15
|
+
default: "bg-gray-600 text-white",
|
|
16
|
+
brand: "bg-brand text-white",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function Badge({
|
|
20
|
+
variant = "default",
|
|
21
|
+
className,
|
|
22
|
+
children,
|
|
23
|
+
...props
|
|
24
|
+
}: BadgeProps) {
|
|
25
|
+
return (
|
|
26
|
+
<span
|
|
27
|
+
className={cn(
|
|
28
|
+
"inline-flex items-center justify-center px-2.5 py-0.5 rounded-lg",
|
|
29
|
+
"text-xs font-medium leading-4 whitespace-nowrap",
|
|
30
|
+
variantStyles[variant],
|
|
31
|
+
className
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
|
|
3
|
+
export type BannerVariant = "gradient" | "info" | "success" | "warning" | "error";
|
|
4
|
+
|
|
5
|
+
export interface BannerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
variant?: BannerVariant;
|
|
9
|
+
action?: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const variantStyles: Record<BannerVariant, { wrapper: string; title: string; desc: string }> = {
|
|
13
|
+
gradient: {
|
|
14
|
+
wrapper: "border border-[var(--color-surface-border)]",
|
|
15
|
+
title: "text-gray-800",
|
|
16
|
+
desc: "text-gray-600",
|
|
17
|
+
},
|
|
18
|
+
info: {
|
|
19
|
+
wrapper: "bg-info-bg border border-info/20",
|
|
20
|
+
title: "text-info",
|
|
21
|
+
desc: "text-info/80",
|
|
22
|
+
},
|
|
23
|
+
success: {
|
|
24
|
+
wrapper: "bg-success-bg border border-success/20",
|
|
25
|
+
title: "text-success",
|
|
26
|
+
desc: "text-success/80",
|
|
27
|
+
},
|
|
28
|
+
warning: {
|
|
29
|
+
wrapper: "bg-warning-bg border border-warning/20",
|
|
30
|
+
title: "text-warning",
|
|
31
|
+
desc: "text-warning/80",
|
|
32
|
+
},
|
|
33
|
+
error: {
|
|
34
|
+
wrapper: "bg-error-bg border border-error/20",
|
|
35
|
+
title: "text-error",
|
|
36
|
+
desc: "text-error/80",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function Banner({
|
|
41
|
+
title,
|
|
42
|
+
description,
|
|
43
|
+
variant = "gradient",
|
|
44
|
+
action,
|
|
45
|
+
className,
|
|
46
|
+
...props
|
|
47
|
+
}: BannerProps) {
|
|
48
|
+
const styles = variantStyles[variant];
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={cn(
|
|
53
|
+
"rounded-lg p-4 flex items-center justify-between gap-4 shadow-sm",
|
|
54
|
+
styles.wrapper,
|
|
55
|
+
className
|
|
56
|
+
)}
|
|
57
|
+
style={
|
|
58
|
+
variant === "gradient"
|
|
59
|
+
? { background: "linear-gradient(90deg, #F0FDFA 0%, #FFF7ED 100%)" }
|
|
60
|
+
: undefined
|
|
61
|
+
}
|
|
62
|
+
{...props}
|
|
63
|
+
>
|
|
64
|
+
<div className="flex flex-col gap-0.5">
|
|
65
|
+
<p className={cn("font-semibold text-sm", styles.title)}>{title}</p>
|
|
66
|
+
{description && (
|
|
67
|
+
<p className={cn("text-sm", styles.desc)}>{description}</p>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
{action && <div className="shrink-0">{action}</div>}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Loader2 } from "lucide-react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
export type ButtonVariant = "primary" | "secondary" | "destructive" | "ghost" | "outline";
|
|
5
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
6
|
+
|
|
7
|
+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
8
|
+
variant?: ButtonVariant;
|
|
9
|
+
size?: ButtonSize;
|
|
10
|
+
loading?: boolean;
|
|
11
|
+
children?: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const variantStyles: Record<ButtonVariant, string> = {
|
|
15
|
+
primary:
|
|
16
|
+
"bg-brand text-white hover:bg-brand-dark active:bg-brand-dark shadow-sm",
|
|
17
|
+
secondary:
|
|
18
|
+
"bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-200",
|
|
19
|
+
destructive:
|
|
20
|
+
"bg-error text-white hover:opacity-90 active:opacity-90 shadow-sm",
|
|
21
|
+
ghost:
|
|
22
|
+
"bg-transparent text-gray-700 hover:bg-gray-100 active:bg-gray-100",
|
|
23
|
+
outline:
|
|
24
|
+
"bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 shadow-sm",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const sizeStyles: Record<ButtonSize, string> = {
|
|
28
|
+
sm: "h-7 px-3 text-xs rounded-md gap-1.5",
|
|
29
|
+
md: "h-9 px-4 text-sm rounded-lg gap-2",
|
|
30
|
+
lg: "h-11 px-6 text-base rounded-lg gap-2.5",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function Button({
|
|
34
|
+
variant = "primary",
|
|
35
|
+
size = "md",
|
|
36
|
+
loading = false,
|
|
37
|
+
disabled,
|
|
38
|
+
className,
|
|
39
|
+
children,
|
|
40
|
+
...props
|
|
41
|
+
}: ButtonProps) {
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
className={cn(
|
|
45
|
+
"inline-flex items-center justify-center font-medium transition-colors",
|
|
46
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50",
|
|
47
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
48
|
+
variantStyles[variant],
|
|
49
|
+
sizeStyles[size],
|
|
50
|
+
className
|
|
51
|
+
)}
|
|
52
|
+
disabled={disabled || loading}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
{loading && <Loader2 className="animate-spin" size={size === "lg" ? 18 : 14} />}
|
|
56
|
+
{children}
|
|
57
|
+
</button>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { Badge } from "./badge";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
|
|
7
|
+
/* ── Discriminated union ──────────────────────────────── */
|
|
8
|
+
|
|
9
|
+
type BalanceCardProps = {
|
|
10
|
+
variant: "balance";
|
|
11
|
+
title: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
amount: string;
|
|
14
|
+
action?: React.ReactNode;
|
|
15
|
+
loading?: boolean;
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ModuleCardProps = {
|
|
20
|
+
variant: "module";
|
|
21
|
+
title: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
status?: "success" | "warning" | "error" | "info" | "default";
|
|
24
|
+
statusLabel?: string;
|
|
25
|
+
onEdit?: () => void;
|
|
26
|
+
onView?: () => void;
|
|
27
|
+
loading?: boolean;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type CardProps = BalanceCardProps | ModuleCardProps;
|
|
32
|
+
|
|
33
|
+
/* ── CardContainer — generic composable card ─────────── */
|
|
34
|
+
|
|
35
|
+
export interface CardContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function CardContainer({ className, children, ...props }: CardContainerProps) {
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className={cn("bg-white rounded-lg border border-[#E5E5E5] shadow-sm", className)}
|
|
43
|
+
{...props}
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ── Card ─────────────────────────────────────────────── */
|
|
51
|
+
|
|
52
|
+
export function Card(props: CardProps) {
|
|
53
|
+
if (props.variant === "balance") {
|
|
54
|
+
return <BalanceCard {...props} />;
|
|
55
|
+
}
|
|
56
|
+
return <ModuleCard {...props} />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ── Internal: shell ──────────────────────────────────── */
|
|
60
|
+
|
|
61
|
+
function CardShell({ className, children }: { className?: string; children: React.ReactNode }) {
|
|
62
|
+
return (
|
|
63
|
+
<div className={cn("bg-white rounded-lg border border-[#E5E5E5] shadow-sm", className)}>
|
|
64
|
+
{children}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ── Internal: skeleton block ─────────────────────────── */
|
|
70
|
+
|
|
71
|
+
function Skeleton({ className }: { className?: string }) {
|
|
72
|
+
return <div className={cn("animate-pulse rounded bg-gray-200", className)} />;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ── Internal: BalanceCard ────────────────────────────── */
|
|
76
|
+
|
|
77
|
+
function BalanceCard({ title, description, amount, action, loading, className }: BalanceCardProps) {
|
|
78
|
+
return (
|
|
79
|
+
<CardShell className={className}>
|
|
80
|
+
<div className="px-6 pt-6 pb-4">
|
|
81
|
+
{loading ? (
|
|
82
|
+
<>
|
|
83
|
+
<Skeleton className="h-4 w-32" />
|
|
84
|
+
{description !== undefined && <Skeleton className="mt-2 h-3 w-48" />}
|
|
85
|
+
</>
|
|
86
|
+
) : (
|
|
87
|
+
<>
|
|
88
|
+
<h3 className="text-base font-semibold text-gray-900 leading-tight">{title}</h3>
|
|
89
|
+
{description && (
|
|
90
|
+
<p className="mt-1 text-sm text-gray-500 leading-normal">{description}</p>
|
|
91
|
+
)}
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
<div className="px-6 pb-4">
|
|
96
|
+
{loading ? (
|
|
97
|
+
<Skeleton className="h-9 w-36" />
|
|
98
|
+
) : (
|
|
99
|
+
<p className="text-3xl font-bold text-gray-900">{amount}</p>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
{action && (
|
|
103
|
+
<div className="flex items-center px-6 py-4 border-t border-[#E5E5E5] bg-gray-50 rounded-b-lg">
|
|
104
|
+
{action}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</CardShell>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ── Internal: ModuleCard ─────────────────────────────── */
|
|
112
|
+
|
|
113
|
+
function ModuleCard({ title, description, status, statusLabel, onEdit, onView, loading, className }: ModuleCardProps) {
|
|
114
|
+
return (
|
|
115
|
+
<CardShell className={className}>
|
|
116
|
+
<div className="px-6 pt-6 pb-4">
|
|
117
|
+
{loading ? (
|
|
118
|
+
<>
|
|
119
|
+
<div className="flex items-center justify-between">
|
|
120
|
+
<Skeleton className="h-4 w-36" />
|
|
121
|
+
<Skeleton className="h-5 w-16 rounded-full" />
|
|
122
|
+
</div>
|
|
123
|
+
{description !== undefined && <Skeleton className="mt-2 h-3 w-52" />}
|
|
124
|
+
</>
|
|
125
|
+
) : (
|
|
126
|
+
<>
|
|
127
|
+
<div className="flex items-center justify-between">
|
|
128
|
+
<h3 className="text-base font-semibold text-gray-900 leading-tight">{title}</h3>
|
|
129
|
+
{status && statusLabel && (
|
|
130
|
+
<Badge variant={status}>{statusLabel}</Badge>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
{description && (
|
|
134
|
+
<p className="mt-1 text-sm text-gray-500 leading-normal">{description}</p>
|
|
135
|
+
)}
|
|
136
|
+
</>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
{(onEdit || onView) && (
|
|
140
|
+
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-[#E5E5E5] bg-gray-50 rounded-b-lg">
|
|
141
|
+
{loading ? (
|
|
142
|
+
<>
|
|
143
|
+
<Skeleton className="h-7 w-12" />
|
|
144
|
+
<Skeleton className="h-7 w-12" />
|
|
145
|
+
</>
|
|
146
|
+
) : (
|
|
147
|
+
<>
|
|
148
|
+
{onEdit && <Button variant="ghost" size="sm" onClick={onEdit}>Edit</Button>}
|
|
149
|
+
{onView && <Button variant="primary" size="sm" onClick={onView}>View</Button>}
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</CardShell>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { Copy } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { useToast } from "@/components/ui/toast";
|
|
7
|
+
import type { ThemedToken } from "shiki";
|
|
8
|
+
|
|
9
|
+
export interface CodeBlockProps {
|
|
10
|
+
code: string;
|
|
11
|
+
language?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type TokenLine = ThemedToken[];
|
|
17
|
+
|
|
18
|
+
export function CodeBlock({
|
|
19
|
+
code,
|
|
20
|
+
language = "bash",
|
|
21
|
+
title,
|
|
22
|
+
className,
|
|
23
|
+
}: CodeBlockProps) {
|
|
24
|
+
const [tokens, setTokens] = useState<TokenLine[] | null>(null);
|
|
25
|
+
const [bg, setBg] = useState("#000000");
|
|
26
|
+
const [fg, setFg] = useState("#d4d4d4");
|
|
27
|
+
const [copied, setCopied] = useState(false);
|
|
28
|
+
const { showToast } = useToast();
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const highlight = async () => {
|
|
32
|
+
try {
|
|
33
|
+
const { codeToTokens } = await import("shiki");
|
|
34
|
+
const result = await codeToTokens(code.trim(), {
|
|
35
|
+
lang: language as Parameters<typeof codeToTokens>[1]["lang"],
|
|
36
|
+
theme: "dark-plus",
|
|
37
|
+
});
|
|
38
|
+
setTokens(result.tokens);
|
|
39
|
+
if (result.bg) setBg(result.bg);
|
|
40
|
+
if (result.fg) setFg(result.fg);
|
|
41
|
+
} catch {
|
|
42
|
+
// leave tokens null — fallback pre renders raw code
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
highlight();
|
|
46
|
+
}, [code, language]);
|
|
47
|
+
|
|
48
|
+
const handleCopy = () => {
|
|
49
|
+
navigator.clipboard.writeText(code);
|
|
50
|
+
setCopied(true);
|
|
51
|
+
showToast("Copied to clipboard!", "success");
|
|
52
|
+
setTimeout(() => setCopied(false), 1500);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const copyButton = (
|
|
56
|
+
<button
|
|
57
|
+
onClick={handleCopy}
|
|
58
|
+
className="flex items-center gap-1 bg-gray-700 hover:bg-gray-600 text-gray-200 px-2 py-1 rounded text-xs transition-colors"
|
|
59
|
+
>
|
|
60
|
+
<Copy size={13} />
|
|
61
|
+
{copied ? "Copied!" : "Copy"}
|
|
62
|
+
</button>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className={cn(
|
|
68
|
+
"relative border border-gray-700 rounded-lg overflow-hidden",
|
|
69
|
+
className
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{title ? (
|
|
73
|
+
<div className="flex items-center justify-between bg-[#161B22] px-4 py-2 text-sm text-gray-300 border-b border-gray-700">
|
|
74
|
+
<span className="truncate">{title}</span>
|
|
75
|
+
{copyButton}
|
|
76
|
+
</div>
|
|
77
|
+
) : (
|
|
78
|
+
<div className="absolute top-2 right-2 z-10">{copyButton}</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
{tokens === null ? (
|
|
82
|
+
<pre
|
|
83
|
+
className="p-4 overflow-x-auto text-sm font-mono"
|
|
84
|
+
style={{ background: bg, color: fg }}
|
|
85
|
+
>
|
|
86
|
+
<code>{code}</code>
|
|
87
|
+
</pre>
|
|
88
|
+
) : (
|
|
89
|
+
<pre
|
|
90
|
+
className="p-4 overflow-x-auto text-sm font-mono !m-0 !rounded-none"
|
|
91
|
+
style={{ background: bg, color: fg }}
|
|
92
|
+
>
|
|
93
|
+
<code>
|
|
94
|
+
{tokens.map((line, i) => (
|
|
95
|
+
<span key={i} className="block">
|
|
96
|
+
{line.map((token, j) => (
|
|
97
|
+
<span key={j} style={{ color: token.color }}>
|
|
98
|
+
{token.content}
|
|
99
|
+
</span>
|
|
100
|
+
))}
|
|
101
|
+
</span>
|
|
102
|
+
))}
|
|
103
|
+
</code>
|
|
104
|
+
</pre>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|