Production-ready improvements: security hardening, Redis sessions, retry logic, updated pricing
Changes: - Removed hardcoded admin credentials (now requires env vars) - Added Redis session storage with in-memory fallback - Improved CORS configuration with warnings for development mode - Added retry_with_backoff decorator for translation API calls - Updated pricing: Starter=, Pro=, Business= - Stripe price IDs now loaded from environment variables - Added redis to requirements.txt - Updated .env.example with all new configuration options - Created COMPREHENSIVE_REVIEW_AND_PLAN.md with deployment roadmap - Frontend: Updated pricing page, new UI components
This commit is contained in:
@@ -1,46 +1,295 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-all duration-200 ease-out focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
"border-transparent bg-primary text-primary-foreground shadow-sm hover:shadow-md hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
"border-transparent bg-surface text-secondary-foreground hover:bg-surface-hover",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80",
|
||||
outline: "text-foreground border-border hover:bg-surface hover:border-border-strong",
|
||||
success:
|
||||
"border-transparent bg-success text-success-foreground shadow-sm hover:bg-success/80",
|
||||
warning:
|
||||
"border-transparent bg-warning text-warning-foreground shadow-sm hover:bg-warning/80",
|
||||
info:
|
||||
"border-transparent bg-primary/20 text-primary border-primary/30 hover:bg-primary/30",
|
||||
accent:
|
||||
"border-transparent bg-accent text-accent-foreground shadow-sm hover:bg-accent/80",
|
||||
glass:
|
||||
"glass text-foreground border-border/20 hover:bg-surface/50 hover:border-border/40",
|
||||
gradient:
|
||||
"border-transparent bg-gradient-to-r from-primary to-accent text-white shadow-lg hover:shadow-xl hover:shadow-primary/25",
|
||||
neon:
|
||||
"border-transparent bg-primary/10 text-primary border-primary/20 shadow-lg shadow-primary/20 hover:bg-primary/20 hover:border-primary/30 hover:shadow-primary/30",
|
||||
pulse:
|
||||
"border-transparent bg-primary text-primary-foreground shadow-lg shadow-primary/25 animate-pulse hover:animate-none",
|
||||
dot:
|
||||
"border-transparent bg-primary text-primary-foreground w-2 h-2 p-0 rounded-full",
|
||||
premium:
|
||||
"border-transparent bg-gradient-to-r from-primary via-accent to-primary text-white shadow-lg hover:shadow-xl hover:shadow-primary/25 relative overflow-hidden",
|
||||
},
|
||||
size: {
|
||||
default: "px-2.5 py-0.5 text-xs",
|
||||
sm: "px-2 py-0.5 text-xs",
|
||||
lg: "px-3 py-1 text-sm",
|
||||
xl: "px-4 py-1.5 text-base",
|
||||
icon: "w-6 h-6 p-0 rounded-lg flex items-center justify-center",
|
||||
dot: "w-2 h-2 p-0 rounded-full",
|
||||
},
|
||||
interactive: {
|
||||
true: "cursor-pointer hover:scale-105 active:scale-95",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
interactive: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {
|
||||
icon?: React.ReactNode
|
||||
removable?: boolean
|
||||
onRemove?: () => void
|
||||
pulse?: boolean
|
||||
count?: number
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
interactive = false,
|
||||
icon,
|
||||
removable = false,
|
||||
onRemove,
|
||||
pulse = false,
|
||||
count,
|
||||
maxCount = 99,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [visible, setVisible] = React.useState(true)
|
||||
const [removing, setRemoving] = React.useState(false)
|
||||
|
||||
const handleRemove = () => {
|
||||
setRemoving(true)
|
||||
setTimeout(() => {
|
||||
setVisible(false)
|
||||
onRemove?.()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const displayCount = count && count > maxCount ? `${maxCount}+` : count
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
badgeVariants({ variant, size, interactive }),
|
||||
pulse && "animate-pulse",
|
||||
removing && "scale-0 opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Gradient Shine Effect */}
|
||||
{(variant === "premium" || variant === "gradient") && (
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -skew-x-12 -translate-x-full group-hover:translate-x-full transition-transform duration-1000 ease-out pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{icon && (
|
||||
<span className="mr-1 flex-shrink-0">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<span className="flex items-center gap-1">
|
||||
{children}
|
||||
{displayCount && (
|
||||
<span className="ml-1 bg-white/20 px-1.5 py-0.5 rounded text-xs font-bold">
|
||||
{displayCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Remove Button */}
|
||||
{removable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="ml-1 flex-shrink-0 rounded-full bg-white/20 hover:bg-white/30 transition-colors p-0.5"
|
||||
aria-label="Remove badge"
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Pulse Ring for Neon Variant */}
|
||||
{variant === "neon" && (
|
||||
<>
|
||||
<span className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||||
<span className="absolute inset-0 rounded-full bg-primary/10 animate-ping animation-delay-200" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Badge.displayName = "Badge"
|
||||
|
||||
// Status Badge Component
|
||||
export const StatusBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
Omit<BadgeProps, 'variant'> & {
|
||||
status: "online" | "offline" | "busy" | "away" | "success" | "error" | "warning"
|
||||
showLabel?: boolean
|
||||
}
|
||||
>(({ className, status, showLabel = true, children, ...props }, ref) => {
|
||||
const statusConfig = {
|
||||
online: { variant: "success" as const, label: "Online", icon: "●" },
|
||||
offline: { variant: "secondary" as const, label: "Offline", icon: "○" },
|
||||
busy: { variant: "destructive" as const, label: "Busy", icon: "◐" },
|
||||
away: { variant: "warning" as const, label: "Away", icon: "◐" },
|
||||
success: { variant: "success" as const, label: "Success", icon: "✓" },
|
||||
error: { variant: "destructive" as const, label: "Error", icon: "✕" },
|
||||
warning: { variant: "warning" as const, label: "Warning", icon: "!" },
|
||||
}
|
||||
|
||||
const config = statusConfig[status]
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
<Badge
|
||||
ref={ref}
|
||||
variant={config.variant}
|
||||
className={cn("gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<span className="relative">
|
||||
<span className="text-xs">{config.icon}</span>
|
||||
{status === "online" && (
|
||||
<span className="absolute inset-0 rounded-full bg-success animate-ping" />
|
||||
)}
|
||||
</span>
|
||||
{showLabel && (
|
||||
<span>{children || config.label}</span>
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
})
|
||||
StatusBadge.displayName = "StatusBadge"
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
// Counter Badge Component
|
||||
export const CounterBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
Omit<BadgeProps, 'children'> & {
|
||||
value: number
|
||||
showZero?: boolean
|
||||
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left"
|
||||
}
|
||||
>(({
|
||||
className,
|
||||
value,
|
||||
showZero = false,
|
||||
position = "top-right",
|
||||
size = "icon",
|
||||
...props
|
||||
}, ref) => {
|
||||
if (value === 0 && !showZero) return null
|
||||
|
||||
const positionClasses = {
|
||||
"top-right": "-top-2 -right-2",
|
||||
"top-left": "-top-2 -left-2",
|
||||
"bottom-right": "-bottom-2 -right-2",
|
||||
"bottom-left": "-bottom-2 -left-2",
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
ref={ref}
|
||||
variant="destructive"
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute min-w-[20px] h-5 flex items-center justify-center text-xs font-bold",
|
||||
positionClasses[position],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{value > 99 ? "99+" : value}
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
CounterBadge.displayName = "CounterBadge"
|
||||
|
||||
// Progress Badge Component
|
||||
export const ProgressBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
Omit<BadgeProps, 'children'> & {
|
||||
value: number
|
||||
max?: number
|
||||
showPercentage?: boolean
|
||||
}
|
||||
>(({
|
||||
className,
|
||||
value,
|
||||
max = 100,
|
||||
showPercentage = true,
|
||||
...props
|
||||
}, ref) => {
|
||||
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
|
||||
const displayValue = showPercentage ? `${Math.round(percentage)}%` : `${value}/${max}`
|
||||
|
||||
return (
|
||||
<Badge
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Progress Background */}
|
||||
<span
|
||||
className="absolute inset-0 bg-primary/20"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
|
||||
{/* Text */}
|
||||
<span className="relative z-10">{displayValue}</span>
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
ProgressBadge.displayName = "ProgressBadge"
|
||||
|
||||
export { Badge, badgeVariants, StatusBadge, CounterBadge, ProgressBadge }
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 ease-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive relative overflow-hidden group",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-lg hover:shadow-xl hover:shadow-primary/25 transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 shadow-lg hover:shadow-xl hover:shadow-destructive/25 transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"border border-border bg-transparent shadow-sm hover:bg-surface hover:text-accent-foreground hover:border-accent hover:shadow-md transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-surface text-secondary-foreground hover:bg-surface-hover hover:text-secondary-foreground/80 border border-border-subtle shadow-sm hover:shadow-md transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"hover:bg-surface-hover hover:text-accent-foreground dark:hover:bg-surface/50 transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
link: "text-primary underline-offset-4 hover:underline decoration-2 decoration-primary/60 hover:decoration-primary transition-all duration-200",
|
||||
premium: "bg-gradient-to-r from-primary to-accent text-white hover:from-primary/90 hover:to-accent/90 shadow-lg hover:shadow-xl hover:shadow-primary/25 transform hover:-translate-y-0.5 active:translate-y-0 relative overflow-hidden",
|
||||
glass: "glass text-foreground hover:bg-surface/50 border border-border-subtle hover:border-border shadow-md hover:shadow-lg transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
default: "h-10 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-xs",
|
||||
lg: "h-12 rounded-xl px-6 has-[>svg]:px-4 text-base",
|
||||
icon: "size-10 rounded-lg",
|
||||
"icon-sm": "size-8 rounded-md",
|
||||
"icon-lg": "size-12 rounded-xl",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -36,25 +37,107 @@ const buttonVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
loading?: boolean
|
||||
ripple?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, loading = false, ripple = true, children, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const [ripples, setRipples] = React.useState<Array<{ id: number; x: number; y: number; size: number }>>([])
|
||||
|
||||
const createRipple = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!ripple || disabled || loading) return
|
||||
|
||||
const button = event.currentTarget
|
||||
const rect = button.getBoundingClientRect()
|
||||
const size = Math.max(rect.width, rect.height)
|
||||
const x = event.clientX - rect.left - size / 2
|
||||
const y = event.clientY - rect.top - size / 2
|
||||
|
||||
const newRipple = {
|
||||
id: Date.now(),
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
}
|
||||
|
||||
setRipples(prev => [...prev, newRipple])
|
||||
|
||||
// Remove ripple after animation
|
||||
setTimeout(() => {
|
||||
setRipples(prev => prev.filter(r => r.id !== newRipple.id))
|
||||
}, 600)
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
onClick={createRipple}
|
||||
{...props}
|
||||
>
|
||||
{/* Ripple Effect */}
|
||||
{ripple && (
|
||||
<span className="absolute inset-0 overflow-hidden rounded-[inherit]">
|
||||
{ripples.map(ripple => (
|
||||
<span
|
||||
key={ripple.id}
|
||||
className="absolute bg-white/20 rounded-full animate-ping"
|
||||
style={{
|
||||
left: ripple.x,
|
||||
top: ripple.y,
|
||||
width: ripple.size,
|
||||
height: ripple.size,
|
||||
animation: 'ripple 0.6s ease-out',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Button Content */}
|
||||
<span className={cn("flex items-center gap-2", loading && "opacity-70")}>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
{/* Shine Effect for Premium Variant */}
|
||||
{variant === 'premium' && (
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -skew-x-12 -translate-x-full group-hover:translate-x-full transition-transform duration-1000 ease-out pointer-events-none" />
|
||||
)}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -1,92 +1,312 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "elevated" | "glass" | "gradient"
|
||||
hover?: boolean
|
||||
interactive?: boolean
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant = "default", hover = true, interactive = false, ...props }, ref) => {
|
||||
const variants = {
|
||||
default: "bg-card text-card-foreground border border-border shadow-sm",
|
||||
elevated: "bg-surface-elevated text-card-foreground border border-border shadow-lg",
|
||||
glass: "glass text-card-foreground border border-border/20 shadow-lg backdrop-blur-xl",
|
||||
gradient: "bg-gradient-to-br from-surface to-surface-elevated text-card-foreground border border-border/50 shadow-xl"
|
||||
}
|
||||
|
||||
const hoverClasses = hover ? `
|
||||
transition-all duration-300 ease-out
|
||||
${interactive ? 'cursor-pointer' : ''}
|
||||
${hover ? 'hover:shadow-xl hover:shadow-primary/10 hover:-translate-y-1' : ''}
|
||||
${interactive ? 'active:translate-y-0 active:shadow-lg' : ''}
|
||||
` : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl p-6 relative overflow-hidden",
|
||||
variants[variant],
|
||||
hoverClasses,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
title?: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
}
|
||||
>(({ className, title, description, action, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
"grid auto-rows-min grid-rows-[auto_auto] items-start gap-3 relative",
|
||||
action && "grid-cols-[1fr_auto]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children || (
|
||||
<>
|
||||
{title && (
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold leading-none text-card-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
<div className="col-start-2 row-span-2 row-start-1 self-start justify-self-end">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div"
|
||||
icon?: React.ReactNode
|
||||
badge?: React.ReactNode
|
||||
}
|
||||
>(({ className, as: Component = "h3", icon, badge, children, ...props }, ref) => {
|
||||
const Comp = Component
|
||||
|
||||
return (
|
||||
<div
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
"leading-none font-semibold text-xl flex items-center gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{icon && (
|
||||
<span className="flex-shrink-0 w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1">{children}</span>
|
||||
{badge && (
|
||||
<span className="flex-shrink-0">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
})
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardAction = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardAction.displayName = "CardAction"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
noPadding?: boolean
|
||||
}
|
||||
>(({ className, noPadding = false, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-content"
|
||||
className={cn(
|
||||
noPadding ? "" : "px-6",
|
||||
"relative",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
sticky?: boolean
|
||||
}
|
||||
>(({ className, sticky = false, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center px-6 py-4",
|
||||
sticky && "sticky bottom-0 bg-card/95 backdrop-blur-sm border-t border-border/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
// Enhanced Card Components
|
||||
const CardStats = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: {
|
||||
value: number
|
||||
type: "increase" | "decrease"
|
||||
period?: string
|
||||
}
|
||||
icon?: React.ReactNode
|
||||
trend?: "up" | "down" | "stable"
|
||||
}
|
||||
>(({ className, title, value, change, icon, trend, ...props }, ref) => {
|
||||
const trendColors = {
|
||||
up: "text-success",
|
||||
down: "text-destructive",
|
||||
stable: "text-muted-foreground"
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
ref={ref}
|
||||
className={cn("p-6 space-y-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">{title}</span>
|
||||
{icon && (
|
||||
<span className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-2xl font-bold text-card-foreground">
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{change && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
"flex items-center gap-1",
|
||||
change.type === "increase" ? "text-success" : "text-destructive"
|
||||
)}>
|
||||
{change.type === "increase" ? "↑" : "↓"} {Math.abs(change.value)}%
|
||||
</span>
|
||||
{change.period && (
|
||||
<span className="text-muted-foreground">{change.period}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trend && (
|
||||
<div className={cn("text-sm", trendColors[trend])}>
|
||||
{trend === "up" && "↗ Trending up"}
|
||||
{trend === "down" && "↘ Trending down"}
|
||||
{trend === "stable" && "→ Stable"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
CardStats.displayName = "CardStats"
|
||||
|
||||
const CardFeature = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
color?: "primary" | "accent" | "success" | "warning" | "destructive"
|
||||
}
|
||||
>(({ className, icon, title, description, color = "primary", ...props }, ref) => {
|
||||
const colorClasses = {
|
||||
primary: "bg-primary/10 text-primary",
|
||||
accent: "bg-accent/10 text-accent",
|
||||
success: "bg-success/10 text-success",
|
||||
warning: "bg-warning/10 text-warning",
|
||||
destructive: "bg-destructive/10 text-destructive"
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
ref={ref}
|
||||
className={cn("p-6 space-y-4 text-center group", className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<div className={cn(
|
||||
"w-16 h-16 rounded-2xl flex items-center justify-center mx-auto transition-all duration-300 group-hover:scale-110 group-hover:shadow-lg",
|
||||
colorClasses[color]
|
||||
)}>
|
||||
<span className="text-2xl">{icon}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-card-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
CardFeature.displayName = "CardFeature"
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardStats,
|
||||
CardFeature,
|
||||
}
|
||||
|
||||
@@ -1,21 +1,354 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helper?: string
|
||||
leftIcon?: React.ReactNode
|
||||
rightIcon?: React.ReactNode
|
||||
loading?: boolean
|
||||
variant?: "default" | "filled" | "outlined" | "ghost"
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({
|
||||
className,
|
||||
type,
|
||||
label,
|
||||
error,
|
||||
helper,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
loading = false,
|
||||
variant = "default",
|
||||
...props
|
||||
}, ref) => {
|
||||
const [focused, setFocused] = React.useState(false)
|
||||
const [filled, setFilled] = React.useState(false)
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setFocused(true)
|
||||
props.onFocus?.(e)
|
||||
}
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setFocused(false)
|
||||
setFilled(e.target.value.length > 0)
|
||||
props.onBlur?.(e)
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFilled(e.target.value.length > 0)
|
||||
props.onChange?.(e)
|
||||
}
|
||||
|
||||
const variants = {
|
||||
default: `
|
||||
bg-surface border border-border-subtle
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
hover:border-border
|
||||
${error ? 'border-destructive focus:border-destructive focus:ring-destructive/20' : ''}
|
||||
`,
|
||||
filled: `
|
||||
bg-surface-elevated border-0 border-b-2 border-border-subtle
|
||||
focus:border-primary focus:ring-0
|
||||
hover:border-border
|
||||
${error ? 'border-b-destructive focus:border-destructive' : ''}
|
||||
`,
|
||||
outlined: `
|
||||
bg-transparent border-2 border-border-subtle
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
hover:border-border
|
||||
${error ? 'border-destructive focus:border-destructive focus:ring-destructive/20' : ''}
|
||||
`,
|
||||
ghost: `
|
||||
bg-transparent border-0 border-b border-border-subtle
|
||||
focus:border-primary focus:ring-0
|
||||
hover:border-border
|
||||
${error ? 'border-b-destructive focus:border-destructive' : ''}
|
||||
`
|
||||
}
|
||||
|
||||
const inputClasses = cn(
|
||||
"flex h-10 w-full rounded-lg px-3 py-2 text-sm transition-all duration-200 ease-out",
|
||||
"file:text-foreground placeholder:text-muted-foreground",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"outline-none focus:outline-none",
|
||||
"read-only:cursor-default read-only:bg-surface/50",
|
||||
variants[variant],
|
||||
leftIcon && "pl-10",
|
||||
rightIcon && "pr-10",
|
||||
(leftIcon && rightIcon) && "px-10",
|
||||
error && "text-destructive",
|
||||
className
|
||||
)
|
||||
|
||||
const containerClasses = cn(
|
||||
"relative w-full",
|
||||
label && "space-y-2"
|
||||
)
|
||||
|
||||
const labelClasses = cn(
|
||||
"text-sm font-medium transition-all duration-200",
|
||||
error ? "text-destructive" : "text-foreground",
|
||||
focused && "text-primary",
|
||||
filled && "text-muted-foreground"
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{label && (
|
||||
<label className={labelClasses}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{/* Left Icon */}
|
||||
{leftIcon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Field */}
|
||||
<input
|
||||
type={type}
|
||||
className={inputClasses}
|
||||
ref={ref}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${props.id}-error` : helper ? `${props.id}-helper` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{/* Right Icon or Loading */}
|
||||
{(rightIcon || loading) && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">
|
||||
{loading ? (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
rightIcon
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Focus Ring */}
|
||||
{focused && (
|
||||
<div className="absolute inset-0 rounded-lg pointer-events-none">
|
||||
<div className="absolute inset-0 rounded-lg border-2 border-primary/20 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
{helper && !error && (
|
||||
<p className="text-xs text-muted-foreground mt-1" id={`${props.id}-helper`}>
|
||||
{helper}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<p className="text-xs text-destructive mt-1 flex items-center gap-1" id={`${props.id}-error`}>
|
||||
<svg
|
||||
className="h-3 w-3 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
||||
// Enhanced Search Input Component
|
||||
export const SearchInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<InputProps, 'leftIcon'> & {
|
||||
onClear?: () => void
|
||||
showClear?: boolean
|
||||
}
|
||||
>(({ className, showClear = true, onClear, value, ...props }, ref) => {
|
||||
const [focused, setFocused] = React.useState(false)
|
||||
|
||||
const handleClear = () => {
|
||||
onClear?.()
|
||||
// Trigger change event
|
||||
const event = new Event('input', { bubbles: true })
|
||||
const input = ref as React.RefObject<HTMLInputElement>
|
||||
if (input?.current) {
|
||||
input.current.value = ''
|
||||
input.current.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={ref}
|
||||
type="search"
|
||||
leftIcon={
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
rightIcon={
|
||||
showClear && value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"pl-10 pr-10",
|
||||
focused && "shadow-lg shadow-primary/10",
|
||||
className
|
||||
)}
|
||||
onFocus={(e) => {
|
||||
setFocused(true)
|
||||
props.onFocus?.(e)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setFocused(false)
|
||||
props.onBlur?.(e)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
SearchInput.displayName = "SearchInput"
|
||||
|
||||
// File Input Component
|
||||
export const FileInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<InputProps, 'type'> & {
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
onFileSelect?: (files: FileList | null) => void
|
||||
}
|
||||
>(({ className, onFileSelect, accept, multiple = false, ...props }, ref) => {
|
||||
const [dragActive, setDragActive] = React.useState(false)
|
||||
const [fileName, setFileName] = React.useState<string>("")
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
setFileName(e.dataTransfer.files[0].name)
|
||||
onFileSelect?.(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFileName(e.target.files[0].name)
|
||||
onFileSelect?.(e.target.files)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
className={cn(
|
||||
"file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer",
|
||||
dragActive && "border-primary bg-primary/5",
|
||||
className
|
||||
)}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{fileName && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-xs text-muted-foreground truncate max-w-32">
|
||||
{fileName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
FileInput.displayName = "FileInput"
|
||||
|
||||
325
frontend/src/components/ui/notification.tsx
Normal file
325
frontend/src/components/ui/notification.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const notificationVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pr-8 shadow-lg transition-all duration-300 ease-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-border bg-card text-foreground",
|
||||
destructive: "border-destructive bg-destructive text-destructive-foreground",
|
||||
success: "border-success bg-success text-success-foreground",
|
||||
warning: "border-warning bg-warning text-warning-foreground",
|
||||
info: "border-primary bg-primary text-primary-foreground",
|
||||
glass: "glass text-foreground border-border/20",
|
||||
},
|
||||
size: {
|
||||
default: "max-w-md",
|
||||
sm: "max-w-sm",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
full: "max-w-full",
|
||||
},
|
||||
position: {
|
||||
"top-right": "fixed top-4 right-4 z-50",
|
||||
"top-left": "fixed top-4 left-4 z-50",
|
||||
"bottom-right": "fixed bottom-4 right-4 z-50",
|
||||
"bottom-left": "fixed bottom-4 left-4 z-50",
|
||||
"top-center": "fixed top-4 left-1/2 transform -translate-x-1/2 z-50",
|
||||
"bottom-center": "fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50",
|
||||
"center": "fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
position: "top-right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface NotificationProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof notificationVariants> {
|
||||
title?: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
loading?: boolean
|
||||
closable?: boolean
|
||||
autoClose?: boolean
|
||||
duration?: number
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
|
||||
({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
position,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
icon,
|
||||
loading = false,
|
||||
closable = true,
|
||||
autoClose = true,
|
||||
duration = 5000,
|
||||
onClose,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [visible, setVisible] = React.useState(true)
|
||||
const [progress, setProgress] = React.useState(100)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoClose && !loading) {
|
||||
const startTime = Date.now()
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const remaining = Math.max(0, 100 - (elapsed / duration) * 100)
|
||||
setProgress(remaining)
|
||||
|
||||
if (remaining === 0) {
|
||||
clearInterval(interval)
|
||||
setVisible(false)
|
||||
onClose?.()
|
||||
}
|
||||
}, 50)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [autoClose, loading, duration, onClose])
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
const defaultIcons = {
|
||||
default: <Info className="h-5 w-5" />,
|
||||
destructive: <AlertCircle className="h-5 w-5" />,
|
||||
success: <CheckCircle className="h-5 w-5" />,
|
||||
warning: <AlertTriangle className="h-5 w-5" />,
|
||||
info: <Info className="h-5 w-5" />,
|
||||
glass: <Info className="h-5 w-5" />,
|
||||
}
|
||||
|
||||
const displayIcon = icon || defaultIcons[variant as keyof typeof defaultIcons] || defaultIcons.default
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(notificationVariants({ variant, size, position }), className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Progress Bar for Auto-close */}
|
||||
{autoClose && !loading && (
|
||||
<div className="absolute top-0 left-0 h-1 bg-white/20">
|
||||
<div
|
||||
className="h-full bg-white/40 transition-all duration-100 ease-linear"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className={cn(
|
||||
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
||||
variant === "success" && "bg-success/20 text-success",
|
||||
variant === "destructive" && "bg-destructive/20 text-destructive",
|
||||
variant === "warning" && "bg-warning/20 text-warning",
|
||||
variant === "info" && "bg-primary/20 text-primary",
|
||||
variant === "default" && "bg-muted text-muted-foreground",
|
||||
variant === "glass" && "bg-surface/50 text-foreground"
|
||||
)}>
|
||||
{loading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
displayIcon
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="grid gap-1 flex-1 min-w-0">
|
||||
{title && (
|
||||
<div className="text-sm font-semibold leading-none">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-sm opacity-90 leading-relaxed">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
{action && (
|
||||
<div className="flex-shrink-0">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
{closable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Notification.displayName = "Notification"
|
||||
|
||||
// Notification Context
|
||||
interface NotificationContextType {
|
||||
notifications: Array<{
|
||||
id: string
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: VariantProps<typeof notificationVariants>["variant"]
|
||||
duration?: number
|
||||
action?: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
closable?: boolean
|
||||
autoClose?: boolean
|
||||
}>
|
||||
notify: (notification: Omit<NotificationContextType["notifications"][0], "id">) => void
|
||||
success: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||
error: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||
warning: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||
info: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||
dismiss: (id: string) => void
|
||||
dismissAll: () => void
|
||||
}
|
||||
|
||||
const NotificationContext = React.createContext<NotificationContextType | undefined>(undefined)
|
||||
|
||||
export function NotificationProvider({ children }: { children: React.ReactNode }) {
|
||||
const [notifications, setNotifications] = React.useState<NotificationContextType["notifications"]>([])
|
||||
|
||||
const notify = React.useCallback(
|
||||
(notification: Omit<NotificationContextType["notifications"][0], "id">) => {
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
setNotifications(prev => [...prev, { ...notification, id }])
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const success = React.useCallback(
|
||||
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||
notify({ ...notification, variant: "success" }),
|
||||
[notify]
|
||||
)
|
||||
|
||||
const error = React.useCallback(
|
||||
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||
notify({ ...notification, variant: "destructive" }),
|
||||
[notify]
|
||||
)
|
||||
|
||||
const warning = React.useCallback(
|
||||
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||
notify({ ...notification, variant: "warning" }),
|
||||
[notify]
|
||||
)
|
||||
|
||||
const info = React.useCallback(
|
||||
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||
notify({ ...notification, variant: "info" }),
|
||||
[notify]
|
||||
)
|
||||
|
||||
const dismiss = React.useCallback((id: string) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id))
|
||||
}, [])
|
||||
|
||||
const dismissAll = React.useCallback(() => {
|
||||
setNotifications([])
|
||||
}, [])
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
notifications,
|
||||
notify,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
}), [notifications, notify, success, error, warning, info, dismiss, dismissAll])
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
<NotificationContainer />
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useNotification() {
|
||||
const context = React.useContext(NotificationContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useNotification must be used within a NotificationProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Notification Container
|
||||
function NotificationContainer() {
|
||||
const { notifications, dismiss } = useNotification()
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 right-0 z-50 flex flex-col-reverse p-4 space-y-2 pointer-events-none">
|
||||
{notifications.map((notification) => (
|
||||
<Notification
|
||||
key={notification.id}
|
||||
position="top-right"
|
||||
variant={notification.variant}
|
||||
title={notification.title}
|
||||
description={notification.description}
|
||||
action={notification.action}
|
||||
icon={notification.icon}
|
||||
closable={notification.closable}
|
||||
autoClose={notification.autoClose}
|
||||
duration={notification.duration}
|
||||
onClose={() => dismiss(notification.id)}
|
||||
className="pointer-events-auto animate-in slide-in-from-top-2"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton Notification for Loading States
|
||||
export const NotificationSkeleton = () => (
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-border skeleton" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-border skeleton" />
|
||||
<div className="h-3 w-1/2 rounded bg-border skeleton" />
|
||||
</div>
|
||||
<div className="w-6 h-6 rounded bg-border skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export { Notification, notificationVariants }
|
||||
313
frontend/src/components/ui/toast.tsx
Normal file
313
frontend/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:animate-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-border bg-card text-foreground",
|
||||
destructive: "border-destructive bg-destructive text-destructive-foreground",
|
||||
success: "border-success bg-success text-success-foreground",
|
||||
warning: "border-warning bg-warning text-warning-foreground",
|
||||
info: "border-primary bg-primary text-primary-foreground",
|
||||
glass: "glass text-foreground border-border/20",
|
||||
},
|
||||
size: {
|
||||
default: "max-w-md",
|
||||
sm: "max-w-sm",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
full: "max-w-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants> & {
|
||||
icon?: React.ReactNode
|
||||
title?: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
duration?: number
|
||||
}
|
||||
>(({ className, variant, size, icon, title, description, action, duration = 5000, ...props }, ref) => {
|
||||
const [open, setOpen] = React.useState(true)
|
||||
|
||||
const defaultIcons = {
|
||||
default: <Info className="h-5 w-5" />,
|
||||
destructive: <AlertCircle className="h-5 w-5" />,
|
||||
success: <CheckCircle className="h-5 w-5" />,
|
||||
warning: <AlertTriangle className="h-5 w-5" />,
|
||||
info: <Info className="h-5 w-5" />,
|
||||
glass: <Info className="h-5 w-5" />,
|
||||
}
|
||||
|
||||
const displayIcon = icon || defaultIcons[variant as keyof typeof defaultIcons] || defaultIcons.default
|
||||
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant, size }), className)}
|
||||
duration={duration}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
{...props}
|
||||
>
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{displayIcon && (
|
||||
<div className={cn(
|
||||
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
||||
variant === "success" && "bg-success/20 text-success",
|
||||
variant === "destructive" && "bg-destructive/20 text-destructive",
|
||||
variant === "warning" && "bg-warning/20 text-warning",
|
||||
variant === "info" && "bg-primary/20 text-primary",
|
||||
variant === "default" && "bg-muted text-muted-foreground",
|
||||
variant === "glass" && "bg-surface/50 text-foreground"
|
||||
)}>
|
||||
{displayIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1 flex-1">
|
||||
{title && (
|
||||
<ToastPrimitives.Title className="text-sm font-semibold leading-none">
|
||||
{title}
|
||||
</ToastPrimitives.Title>
|
||||
)}
|
||||
{description && (
|
||||
<ToastPrimitives.Description className="text-sm opacity-90 leading-relaxed">
|
||||
{description}
|
||||
</ToastPrimitives.Description>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<ToastPrimitives.Action
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive"
|
||||
)}
|
||||
alt={typeof action === 'string' ? action : undefined}
|
||||
>
|
||||
{action}
|
||||
</ToastPrimitives.Action>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToastPrimitives.Close className="absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600">
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
</ToastPrimitives.Root>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<{
|
||||
altText: string
|
||||
onClick: () => void
|
||||
}>
|
||||
|
||||
// Enhanced Toast Hook
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = React.useState<Array<{
|
||||
id: string
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: VariantProps<typeof toastVariants>["variant"]
|
||||
duration?: number
|
||||
action?: ToastActionElement
|
||||
icon?: React.ReactNode
|
||||
}>>([])
|
||||
|
||||
const toast = React.useCallback(
|
||||
({ title, description, variant = "default", duration = 5000, action, icon }: Omit<ToastProps, "id">) => {
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
|
||||
setToasts(prev => [...prev, {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
variant,
|
||||
duration,
|
||||
action,
|
||||
icon,
|
||||
}])
|
||||
|
||||
// Auto remove after duration
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id))
|
||||
}, duration)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const success = React.useCallback(
|
||||
(props: Omit<ToastProps, "variant">) =>
|
||||
toast({ ...props, variant: "success" }),
|
||||
[toast]
|
||||
)
|
||||
|
||||
const error = React.useCallback(
|
||||
(props: Omit<ToastProps, "variant">) =>
|
||||
toast({ ...props, variant: "destructive" }),
|
||||
[toast]
|
||||
)
|
||||
|
||||
const warning = React.useCallback(
|
||||
(props: Omit<ToastProps, "variant">) =>
|
||||
toast({ ...props, variant: "warning" }),
|
||||
[toast]
|
||||
)
|
||||
|
||||
const info = React.useCallback(
|
||||
(props: Omit<ToastProps, "variant">) =>
|
||||
toast({ ...props, variant: "info" }),
|
||||
[toast]
|
||||
)
|
||||
|
||||
const dismiss = React.useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id))
|
||||
}, [])
|
||||
|
||||
const dismissAll = React.useCallback(() => {
|
||||
setToasts([])
|
||||
}, [])
|
||||
|
||||
return {
|
||||
toast,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
toasts,
|
||||
}
|
||||
}
|
||||
|
||||
// Toast Container Component
|
||||
export const ToastContainer = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Individual Toast Component for use in ToastContainer
|
||||
export const ToastItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
toast: {
|
||||
id: string
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: VariantProps<typeof toastVariants>["variant"]
|
||||
duration?: number
|
||||
action?: ToastActionElement
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
>(({ toast, onDismiss, ...props }, ref) => {
|
||||
return (
|
||||
<Toast
|
||||
ref={ref}
|
||||
variant={toast.variant}
|
||||
title={toast.title}
|
||||
description={toast.description}
|
||||
duration={toast.duration}
|
||||
icon={toast.icon}
|
||||
action={toast.action}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onDismiss(toast.id)
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
ToastItem.displayName = "ToastItem"
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
Reference in New Issue
Block a user