Initial commit: igny8 project

This commit is contained in:
igny8
2025-11-09 10:27:02 +00:00
commit 60b8188111
27265 changed files with 4360521 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
import { Link } from "react-router";
interface AlertProps {
variant: "success" | "error" | "warning" | "info"; // Alert type
title: string; // Title of the alert
message: string | React.ReactNode; // Message of the alert (supports string with \n or JSX)
showLink?: boolean; // Whether to show the "Learn More" link
linkHref?: string; // Link URL
linkText?: string; // Link text
}
const Alert: React.FC<AlertProps> = ({
variant,
title,
message,
showLink = false,
linkHref = "#",
linkText = "Learn more",
}) => {
// Tailwind classes for each variant - matching notification style from image
const variantClasses = {
success: {
container:
"border-b-2 border-success-500 bg-success-50 dark:border-success-500/30 dark:bg-success-500/15",
icon: "text-success-500",
},
error: {
container:
"border-b-2 border-error-500 bg-error-50 dark:border-error-500/30 dark:bg-error-500/15",
icon: "text-error-500",
},
warning: {
container:
"border-b-2 border-warning-500 bg-warning-50 dark:border-warning-500/30 dark:bg-warning-500/15",
icon: "text-warning-500",
},
info: {
container:
"border-b-2 border-blue-light-500 bg-blue-light-50 dark:border-blue-light-500/30 dark:bg-blue-light-500/15",
icon: "text-blue-light-500",
},
};
// Icon for each variant
const icons = {
success: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.70186 12.0001C3.70186 7.41711 7.41711 3.70186 12.0001 3.70186C16.5831 3.70186 20.2984 7.41711 20.2984 12.0001C20.2984 16.5831 16.5831 20.2984 12.0001 20.2984C7.41711 20.2984 3.70186 16.5831 3.70186 12.0001ZM12.0001 1.90186C6.423 1.90186 1.90186 6.423 1.90186 12.0001C1.90186 17.5772 6.423 22.0984 12.0001 22.0984C17.5772 22.0984 22.0984 17.5772 22.0984 12.0001C22.0984 6.423 17.5772 1.90186 12.0001 1.90186ZM15.6197 10.7395C15.9712 10.388 15.9712 9.81819 15.6197 9.46672C15.2683 9.11525 14.6984 9.11525 14.347 9.46672L11.1894 12.6243L9.6533 11.0883C9.30183 10.7368 8.73198 10.7368 8.38051 11.0883C8.02904 11.4397 8.02904 12.0096 8.38051 12.3611L10.553 14.5335C10.7217 14.7023 10.9507 14.7971 11.1894 14.7971C11.428 14.7971 11.657 14.7023 11.8257 14.5335L15.6197 10.7395Z"
/>
</svg>
),
error: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20.3499 12.0004C20.3499 16.612 16.6115 20.3504 11.9999 20.3504C7.38832 20.3504 3.6499 16.612 3.6499 12.0004C3.6499 7.38881 7.38833 3.65039 11.9999 3.65039C16.6115 3.65039 20.3499 7.38881 20.3499 12.0004ZM11.9999 22.1504C17.6056 22.1504 22.1499 17.6061 22.1499 12.0004C22.1499 6.3947 17.6056 1.85039 11.9999 1.85039C6.39421 1.85039 1.8499 6.3947 1.8499 12.0004C1.8499 17.6061 6.39421 22.1504 11.9999 22.1504ZM13.0008 16.4753C13.0008 15.923 12.5531 15.4753 12.0008 15.4753L11.9998 15.4753C11.4475 15.4753 10.9998 15.923 10.9998 16.4753C10.9998 17.0276 11.4475 17.4753 11.9998 17.4753L12.0008 17.4753C12.5531 17.4753 13.0008 17.0276 13.0008 16.4753ZM11.9998 6.62898C12.414 6.62898 12.7498 6.96476 12.7498 7.37898L12.7498 13.0555C12.7498 13.4697 12.414 13.8055 11.9998 13.8055C11.5856 13.8055 11.2498 13.4697 11.2498 13.0555L11.2498 7.37898C11.2498 6.96476 11.5856 6.62898 11.9998 6.62898Z"
fill="#F04438"
/>
</svg>
),
warning: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.6501 12.0001C3.6501 7.38852 7.38852 3.6501 12.0001 3.6501C16.6117 3.6501 20.3501 7.38852 20.3501 12.0001C20.3501 16.6117 16.6117 20.3501 12.0001 20.3501C7.38852 20.3501 3.6501 16.6117 3.6501 12.0001ZM12.0001 1.8501C6.39441 1.8501 1.8501 6.39441 1.8501 12.0001C1.8501 17.6058 6.39441 22.1501 12.0001 22.1501C17.6058 22.1501 22.1501 17.6058 22.1501 12.0001C22.1501 6.39441 17.6058 1.8501 12.0001 1.8501ZM10.9992 7.52517C10.9992 8.07746 11.4469 8.52517 11.9992 8.52517H12.0002C12.5525 8.52517 13.0002 8.07746 13.0002 7.52517C13.0002 6.97289 12.5525 6.52517 12.0002 6.52517H11.9992C11.4469 6.52517 10.9992 6.97289 10.9992 7.52517ZM12.0002 17.3715C11.586 17.3715 11.2502 17.0357 11.2502 16.6215V10.945C11.2502 10.5308 11.586 10.195 12.0002 10.195C12.4144 10.195 12.7502 10.5308 12.7502 10.945V16.6215C12.7502 17.0357 12.4144 17.3715 12.0002 17.3715Z"
fill=""
/>
</svg>
),
info: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.6501 11.9996C3.6501 7.38803 7.38852 3.64961 12.0001 3.64961C16.6117 3.64961 20.3501 7.38803 20.3501 11.9996C20.3501 16.6112 16.6117 20.3496 12.0001 20.3496C7.38852 20.3496 3.6501 16.6112 3.6501 11.9996ZM12.0001 1.84961C6.39441 1.84961 1.8501 6.39392 1.8501 11.9996C1.8501 17.6053 6.39441 22.1496 12.0001 22.1496C17.6058 22.1496 22.1501 17.6053 22.1501 11.9996C22.1501 6.39392 17.6058 1.84961 12.0001 1.84961ZM10.9992 7.52468C10.9992 8.07697 11.4469 8.52468 11.9992 8.52468H12.0002C12.5525 8.52468 13.0002 8.07697 13.0002 7.52468C13.0002 6.9724 12.5525 6.52468 12.0002 6.52468H11.9992C11.4469 6.52468 10.9992 6.9724 10.9992 7.52468ZM12.0002 17.371C11.586 17.371 11.2502 17.0352 11.2502 16.621V10.9445C11.2502 10.5303 11.586 10.1945 12.0002 10.1945C12.4144 10.1945 12.7502 10.5303 12.7502 10.9445V16.621C12.7502 17.0352 12.4144 17.371 12.0002 17.371Z"
fill=""
/>
</svg>
),
};
return (
<div
className={`rounded-xl p-4 ${variantClasses[variant].container}`}
>
<div className="flex items-start gap-3">
<div className={`-mt-0.5 ${variantClasses[variant].icon}`}>
{icons[variant]}
</div>
<div>
<h4 className="mb-1 text-sm font-semibold text-gray-800 dark:text-white/90">
{title}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-line">
{message}
</p>
{showLink && (
<Link
to={linkHref}
className="inline-block mt-3 text-sm font-medium text-gray-500 underline dark:text-gray-400"
>
{linkText}
</Link>
)}
</div>
</div>
</div>
);
};
export default Alert;

View File

@@ -0,0 +1,200 @@
import React from 'react';
import { Modal } from '../modal';
import Button from '../button/Button';
export type AlertModalVariant = 'success' | 'info' | 'warning' | 'danger';
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
message: string;
variant?: AlertModalVariant;
buttonText?: string;
// Confirmation mode props
isConfirmation?: boolean;
onConfirm?: () => void;
confirmText?: string;
cancelText?: string;
isLoading?: boolean;
itemsList?: string[]; // For showing items being deleted (max 5)
}
export default function AlertModal({
isOpen,
onClose,
title,
message,
variant = 'info',
buttonText = 'Okay, Got It',
isConfirmation = false,
onConfirm,
confirmText = 'Okay, Got It',
cancelText = 'Cancel',
isLoading = false,
itemsList = [],
}: AlertModalProps) {
// Icon configurations for each variant - matching design from images
const iconConfig = {
success: (
<div className="relative flex items-center justify-center w-24 h-24 mx-auto mb-6">
{/* Light green flower-like outer shape with rounded petals */}
<div className="absolute inset-0 bg-success-100 rounded-full" style={{
clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
width: '80px',
height: '80px'
}}></div>
{/* Dark green inner circle */}
<div className="relative bg-success-600 rounded-full w-16 h-16 flex items-center justify-center">
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
),
info: (
<div className="relative flex items-center justify-center w-24 h-24 mx-auto mb-6">
{/* Light blue cloud-like background */}
<div className="absolute inset-0 bg-blue-light-100 rounded-full blur-2xl opacity-50" style={{
width: '90px',
height: '90px',
transform: 'scale(1.1)'
}}></div>
{/* Blue circle with 'i' */}
<div className="relative bg-blue-light-500 rounded-full w-16 h-16 flex items-center justify-center shadow-lg">
<span className="text-white text-4xl font-bold leading-none">i</span>
</div>
</div>
),
warning: (
<div className="relative flex items-center justify-center w-24 h-24 mx-auto mb-6">
{/* Light orange cloud-like background */}
<div className="absolute inset-0 bg-warning-100 rounded-full blur-2xl opacity-50" style={{
width: '90px',
height: '90px',
transform: 'scale(1.1)'
}}></div>
{/* White circle */}
<div className="relative bg-white rounded-full w-14 h-14 flex items-center justify-center shadow-lg">
{/* Orange circle with exclamation */}
<div className="bg-warning-500 rounded-full w-16 h-16 flex items-center justify-center absolute -inset-1">
<span className="text-white text-4xl font-bold leading-none">!</span>
</div>
</div>
</div>
),
danger: (
<div className="relative flex items-center justify-center w-24 h-24 mx-auto mb-6">
{/* Light red cloud-like background */}
<div className="absolute inset-0 bg-error-100 rounded-full blur-2xl opacity-50" style={{
width: '90px',
height: '90px',
transform: 'scale(1.1)'
}}></div>
{/* Light red circle with red X */}
<div className="relative bg-error-100 rounded-full w-16 h-16 flex items-center justify-center">
<svg
className="w-10 h-10 text-error-500"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
d="M18.364 5.636a1 1 0 010 1.414L13.414 12l4.95 4.95a1 1 0 11-1.414 1.414L12 13.414l-4.95 4.95a1 1 0 01-1.414-1.414L10.586 12 5.636 7.05a1 1 0 011.414-1.414L12 10.586l4.95-4.95a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
</div>
),
};
// Button color configurations
const buttonConfig = {
success: 'bg-success-500 hover:bg-success-600 text-white',
info: 'bg-blue-light-500 hover:bg-blue-light-600 text-white',
warning: 'bg-warning-500 hover:bg-warning-600 text-white',
danger: 'bg-error-500 hover:bg-error-600 text-white',
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-md"
>
<div className="px-8 py-10 text-center">
{/* Icon */}
{iconConfig[variant]}
{/* Title */}
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-4">
{title}
</h2>
{/* Items List (for delete confirmations) */}
{itemsList.length > 0 && (
<div className="mb-6">
<ul className="text-left text-gray-700 dark:text-gray-300 text-sm space-y-1 max-w-md mx-auto">
{itemsList.slice(0, 5).map((item, index) => (
<li key={index} className="italic">
{item}
</li>
))}
{itemsList.length > 5 && (
<li className="text-gray-500 dark:text-gray-400 italic">
... and {itemsList.length - 5} more
</li>
)}
</ul>
</div>
)}
{/* Message */}
<p className="text-gray-600 dark:text-gray-400 mb-8 text-sm leading-relaxed">
{message}
</p>
{/* Buttons */}
{isConfirmation ? (
<div className="flex justify-center gap-3">
<button
onClick={onClose}
disabled={isLoading}
className="px-6 py-3 rounded-lg font-medium text-sm transition-colors shadow-sm bg-gray-200 hover:bg-gray-300 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{cancelText}
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className={`px-6 py-3 rounded-lg font-medium text-sm transition-colors shadow-sm ${buttonConfig[variant]} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isLoading ? 'Processing...' : confirmText}
</button>
</div>
) : (
<div className="flex justify-center">
<button
onClick={onClose}
className={`px-6 py-3 rounded-lg font-medium text-sm transition-colors shadow-sm ${buttonConfig[variant]}`}
>
{buttonText}
</button>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,55 @@
interface AvatarProps {
src: string; // URL of the avatar image
alt?: string; // Alt text for the avatar
size?: "xsmall" | "small" | "medium" | "large" | "xlarge" | "xxlarge"; // Avatar size
status?: "online" | "offline" | "busy" | "none"; // Status indicator
}
const sizeClasses = {
xsmall: "h-6 w-6 max-w-6",
small: "h-8 w-8 max-w-8",
medium: "h-10 w-10 max-w-10",
large: "h-12 w-12 max-w-12",
xlarge: "h-14 w-14 max-w-14",
xxlarge: "h-16 w-16 max-w-16",
};
const statusSizeClasses = {
xsmall: "h-1.5 w-1.5 max-w-1.5",
small: "h-2 w-2 max-w-2",
medium: "h-2.5 w-2.5 max-w-2.5",
large: "h-3 w-3 max-w-3",
xlarge: "h-3.5 w-3.5 max-w-3.5",
xxlarge: "h-4 w-4 max-w-4",
};
const statusColorClasses = {
online: "bg-success-500",
offline: "bg-error-400",
busy: "bg-warning-500",
};
const Avatar: React.FC<AvatarProps> = ({
src,
alt = "User Avatar",
size = "medium",
status = "none",
}) => {
return (
<div className={`relative rounded-full ${sizeClasses[size]}`}>
{/* Avatar Image */}
<img src={src} alt={alt} className="object-cover rounded-full" />
{/* Status Indicator */}
{status !== "none" && (
<span
className={`absolute bottom-0 right-0 rounded-full border-[1.5px] border-white dark:border-gray-900 ${
statusSizeClasses[size]
} ${statusColorClasses[status] || ""}`}
></span>
)}
</div>
);
};
export default Avatar;

View File

@@ -0,0 +1,85 @@
/**
* Badge Component
*
* 🔒 STYLE LOCKED - See DESIGN_SYSTEM.md for available colors and variants.
* Do not modify colors or add new ones without updating DESIGN_SYSTEM.md first.
*/
type BadgeVariant = "light" | "solid";
type BadgeSize = "sm" | "md";
type BadgeColor =
| "primary"
| "success"
| "error"
| "warning"
| "info"
| "light"
| "dark";
interface BadgeProps {
variant?: BadgeVariant; // Light or solid variant
size?: BadgeSize; // Badge size
color?: BadgeColor; // Badge color
startIcon?: React.ReactNode; // Icon at the start
endIcon?: React.ReactNode; // Icon at the end
children: React.ReactNode; // Badge content
className?: string; // Additional classes
}
const Badge: React.FC<BadgeProps> = ({
variant = "light",
color = "primary",
size = "md",
startIcon,
endIcon,
children,
className = "",
}) => {
const baseStyles =
"inline-flex items-center px-2.5 py-0.5 justify-center gap-1 rounded-full font-medium";
// Define size styles
const sizeStyles = {
sm: "text-theme-xs", // Smaller padding and font size
md: "text-sm", // Default padding and font size
};
// Define color styles for variants
const variants = {
light: {
primary:
"bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400",
success:
"bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500",
error:
"bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500",
warning:
"bg-warning-50 text-warning-600 dark:bg-warning-500/15 dark:text-orange-400",
info: "bg-blue-light-50 text-blue-light-500 dark:bg-blue-light-500/15 dark:text-blue-light-500",
light: "bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80",
dark: "bg-gray-500 text-white dark:bg-white/5 dark:text-white",
},
solid: {
primary: "bg-brand-500 text-white dark:text-white",
success: "bg-success-500 text-white dark:text-white",
error: "bg-error-500 text-white dark:text-white",
warning: "bg-warning-500 text-white dark:text-white",
info: "bg-blue-light-500 text-white dark:text-white",
light: "bg-gray-400 dark:bg-white/5 text-white dark:text-white/80",
dark: "bg-gray-700 text-white dark:text-white",
},
};
// Get styles based on size and color variant
const sizeClass = sizeStyles[size];
const colorStyles = variants[variant][color];
return (
<span className={`${baseStyles} ${sizeClass} ${colorStyles} ${className}`}>
{startIcon && <span className="mr-1">{startIcon}</span>}
{children}
{endIcon && <span className="ml-1">{endIcon}</span>}
</span>
);
};
export default Badge;

View File

@@ -0,0 +1,59 @@
import { ReactNode } from "react";
import { Link } from "react-router";
interface BreadcrumbProps {
items: Array<{
label: string;
path?: string;
icon?: ReactNode;
}>;
className?: string;
}
export const Breadcrumb: React.FC<BreadcrumbProps> = ({
items,
className = "",
}) => {
return (
<nav className={className}>
<ol className="flex items-center gap-1.5">
{items.map((item, index) => (
<li key={index} className="flex items-center gap-1.5">
{index > 0 && (
<svg
className="stroke-current text-gray-400"
width="17"
height="16"
viewBox="0 0 17 16"
fill="none"
>
<path
d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366"
stroke=""
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
{item.path && index < items.length - 1 ? (
<Link
to={item.path}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
{item.icon && <span>{item.icon}</span>}
{item.label}
</Link>
) : (
<span className="text-sm text-gray-800 dark:text-white/90">
{item.icon && <span className="mr-1.5">{item.icon}</span>}
{item.label}
</span>
)}
</li>
))}
</ol>
</nav>
);
};

View File

@@ -0,0 +1,2 @@
export { Breadcrumb } from "./Breadcrumb";

View File

@@ -0,0 +1,51 @@
import { ReactNode } from "react";
interface ButtonGroupProps {
children: ReactNode;
className?: string;
}
export const ButtonGroup: React.FC<ButtonGroupProps> = ({
children,
className = "",
}) => {
return (
<div
className={`inline-flex rounded-lg border border-gray-300 bg-white shadow-theme-xs dark:border-gray-700 dark:bg-gray-800 ${className}`}
>
{children}
</div>
);
};
interface ButtonGroupItemProps {
children: ReactNode;
onClick?: () => void;
isActive?: boolean;
className?: string;
disabled?: boolean;
}
export const ButtonGroupItem: React.FC<ButtonGroupItemProps> = ({
children,
onClick,
isActive = false,
className = "",
disabled = false,
}) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed ${
isActive
? "bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-white"
: ""
} ${className}`}
type="button"
>
{children}
</button>
);
};

View File

@@ -0,0 +1,2 @@
export { ButtonGroup, ButtonGroupItem } from "./ButtonGroup";

View File

@@ -0,0 +1,71 @@
/**
* Button Component
*
* 🔒 STYLE LOCKED - See DESIGN_SYSTEM.md for available variants and sizes.
* Do not modify variants or add new ones without updating DESIGN_SYSTEM.md first.
*/
import { ReactNode, forwardRef } from "react";
interface ButtonProps {
children: ReactNode; // Button text or content
size?: "sm" | "md"; // Button size
variant?: "primary" | "outline" | "secondary" | "success"; // Button variant
startIcon?: ReactNode; // Icon before the text
endIcon?: ReactNode; // Icon after the text
onClick?: () => void; // Click handler
disabled?: boolean; // Disabled state
className?: string; // Additional classes
type?: "button" | "submit" | "reset"; // Button type
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
children,
size = "md",
variant = "primary",
startIcon,
endIcon,
onClick,
className = "",
disabled = false,
type = "button",
}, ref) => {
// Size Classes
const sizeClasses = {
sm: "px-3 py-1.5 text-xs h-8",
md: "px-3 py-2 text-sm h-9",
};
// Variant Classes
const variantClasses = {
primary:
"bg-brand-500 text-white shadow-theme-xs hover:bg-brand-600 disabled:bg-brand-300",
outline:
"bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700 dark:hover:bg-white/[0.03] dark:hover:text-gray-300",
secondary:
"bg-gray-500 text-white border border-gray-500 hover:bg-gray-600 hover:border-gray-600 dark:bg-gray-500 dark:border-gray-500 dark:hover:bg-gray-600 dark:hover:border-gray-600",
success:
"bg-success-500 text-white shadow-theme-xs hover:bg-success-600 disabled:bg-success-300",
};
return (
<button
ref={ref}
type={type}
className={`inline-flex items-center justify-center gap-2 rounded-lg transition ${className} ${
sizeClasses[size]
} ${variantClasses[variant]} ${
disabled ? "cursor-not-allowed opacity-50" : ""
}`}
onClick={onClick}
disabled={disabled}
>
{startIcon && <span className="flex items-center">{startIcon}</span>}
{children}
{endIcon && <span className="flex items-center">{endIcon}</span>}
</button>
);
});
Button.displayName = "Button";
export default Button;

View File

@@ -0,0 +1,174 @@
import { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
onClick?: () => void;
}
export const Card: React.FC<CardProps> = ({
children,
className = "",
onClick,
}) => {
return (
<div
className={`rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] sm:p-6 ${className}`}
onClick={onClick}
>
{children}
</div>
);
};
interface CardImageProps {
src: string;
alt?: string;
className?: string;
}
export const CardImage: React.FC<CardImageProps> = ({
src,
alt = "card",
className = "",
}) => {
return (
<div className="mb-5 overflow-hidden rounded-lg">
<img
alt={alt}
className={`overflow-hidden rounded-lg ${className}`}
src={src}
/>
</div>
);
};
interface CardTitleProps {
children: ReactNode;
className?: string;
}
export const CardTitle: React.FC<CardTitleProps> = ({
children,
className = "",
}) => {
return (
<h4
className={`mb-1 font-medium text-gray-800 text-theme-xl dark:text-white/90 ${className}`}
>
{children}
</h4>
);
};
interface CardContentProps {
children: ReactNode;
className?: string;
}
export const CardContent: React.FC<CardContentProps> = ({
children,
className = "",
}) => {
return (
<div className={className}>
{children}
</div>
);
};
interface CardDescriptionProps {
children: ReactNode;
className?: string;
}
export const CardDescription: React.FC<CardDescriptionProps> = ({
children,
className = "",
}) => {
return (
<p className={`text-sm text-gray-500 dark:text-gray-400 ${className}`}>
{children}
</p>
);
};
interface CardActionProps {
children: ReactNode;
href?: string;
onClick?: () => void;
variant?: "button" | "link";
className?: string;
}
export const CardAction: React.FC<CardActionProps> = ({
children,
href,
onClick,
variant = "button",
className = "",
}) => {
const baseClasses =
variant === "button"
? "inline-flex items-center gap-2 px-4 py-3 mt-4 text-sm font-medium text-white rounded-lg bg-brand-500 shadow-theme-xs hover:bg-brand-600"
: "inline-flex items-center gap-1 mt-4 text-sm text-brand-500 hover:text-brand-600";
if (href) {
return (
<a
href={href}
className={`${baseClasses} ${className}`}
onClick={onClick}
>
{children}
</a>
);
}
return (
<button
className={`${baseClasses} ${className}`}
onClick={onClick}
type="button"
>
{children}
</button>
);
};
interface CardIconProps {
children: ReactNode;
className?: string;
}
export const CardIcon: React.FC<CardIconProps> = ({
children,
className = "",
}) => {
return (
<div
className={`mb-5 flex h-14 max-w-14 items-center justify-center rounded-[10.5px] bg-brand-50 text-brand-500 dark:bg-brand-500/10 ${className}`}
>
{children}
</div>
);
};
interface HorizontalCardProps {
children: ReactNode;
className?: string;
}
export const HorizontalCard: React.FC<HorizontalCardProps> = ({
children,
className = "",
}) => {
return (
<div
className={`flex flex-col gap-5 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-white/[0.03] sm:flex-row sm:items-center sm:gap-6 ${className}`}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,11 @@
export {
Card,
CardImage,
CardTitle,
CardContent,
CardDescription,
CardAction,
CardIcon,
HorizontalCard,
} from "./Card";

View File

@@ -0,0 +1,152 @@
import type React from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
interface DropdownProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
/** Reference element for positioning (button that triggers dropdown) */
anchorRef?: React.RefObject<HTMLElement>;
/** Placement: 'left' | 'right' | 'bottom-left' | 'bottom-right' */
placement?: 'left' | 'right' | 'bottom-left' | 'bottom-right';
}
export const Dropdown: React.FC<DropdownProps> = ({
isOpen,
onClose,
children,
className = "",
anchorRef,
placement = 'right',
}) => {
const dropdownRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
// Calculate position based on anchor element
const updatePosition = useCallback(() => {
if (!isOpen || !anchorRef?.current || !dropdownRef.current) return;
const anchorRect = anchorRef.current.getBoundingClientRect();
const dropdownRect = dropdownRef.current.getBoundingClientRect();
// For fixed positioning, coordinates are relative to viewport (no scroll offset needed)
let top = anchorRect.bottom + 8; // 8px = mt-2 equivalent
let left = anchorRect.left;
// Handle placement - get width from actual render or fallback to className parsing
let dropdownWidth = dropdownRect.width;
if (!dropdownWidth || dropdownWidth === 0) {
// Try to extract width from className (e.g., "w-64" = 256px, "w-48" = 192px)
const widthMatch = className.match(/w-(\d+)/);
if (widthMatch) {
const widthValue = parseInt(widthMatch[1], 10);
dropdownWidth = widthValue * 4; // Tailwind: w-64 = 16rem = 256px, so w-N = N*4px
} else {
dropdownWidth = 192; // Default fallback
}
}
if (placement === 'right') {
// Align dropdown right edge to button right edge
left = anchorRect.right - dropdownWidth;
} else if (placement === 'bottom-right') {
// Position below and align to right edge of button
left = anchorRect.right - dropdownWidth;
} else if (placement === 'bottom-left') {
// Position below and align to left edge of button
left = anchorRect.left;
} else {
// 'left' - align dropdown left edge to button left edge
left = anchorRect.left;
}
// Edge detection - flip if would overflow viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const dropdownHeight = dropdownRect.height || 200;
// Check right edge
if (left + dropdownWidth > viewportWidth) {
left = anchorRect.left - dropdownWidth;
}
// Check bottom edge
if (anchorRect.bottom + dropdownHeight > viewportHeight) {
// Position above instead
top = anchorRect.top - dropdownHeight - 8;
}
// Ensure doesn't go off left edge
if (left < 0) {
left = 8;
}
setPosition({ top, left });
}, [isOpen, anchorRef, placement]);
useEffect(() => {
if (isOpen && anchorRef?.current) {
// Update on scroll/resize
const handleUpdate = () => updatePosition();
window.addEventListener('scroll', handleUpdate, true);
window.addEventListener('resize', handleUpdate);
// Use multiple requestAnimationFrame calls to ensure dropdown is rendered and measured
let rafId2: number | undefined;
const rafId1 = requestAnimationFrame(() => {
updatePosition();
// Second frame to ensure dimensions are calculated
rafId2 = requestAnimationFrame(() => {
updatePosition();
});
});
return () => {
window.removeEventListener('scroll', handleUpdate, true);
window.removeEventListener('resize', handleUpdate);
cancelAnimationFrame(rafId1);
if (rafId2 !== undefined) cancelAnimationFrame(rafId2);
};
}
}, [isOpen, anchorRef, placement, updatePosition]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!(event.target as HTMLElement).closest(".dropdown-toggle")
) {
onClose();
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const dropdownContent = (
<div
ref={dropdownRef}
className={`fixed z-[999999] rounded-xl border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark ${className}`}
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{children}
</div>
);
// Portal to body to escape overflow constraints
return createPortal(dropdownContent, document.body);
};

View File

@@ -0,0 +1,46 @@
import type React from "react";
import { Link } from "react-router";
interface DropdownItemProps {
tag?: "a" | "button";
to?: string;
onClick?: () => void;
onItemClick?: () => void;
baseClassName?: string;
className?: string;
children: React.ReactNode;
}
export const DropdownItem: React.FC<DropdownItemProps> = ({
tag = "button",
to,
onClick,
onItemClick,
baseClassName = "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900",
className = "",
children,
}) => {
const combinedClasses = `${baseClassName} ${className}`.trim();
const handleClick = (event: React.MouseEvent) => {
if (tag === "button") {
event.preventDefault();
}
if (onClick) onClick();
if (onItemClick) onItemClick();
};
if (tag === "a" && to) {
return (
<Link to={to} className={combinedClasses} onClick={handleClick}>
{children}
</Link>
);
}
return (
<button onClick={handleClick} className={combinedClasses}>
{children}
</button>
);
};

View File

@@ -0,0 +1,13 @@
export default function ResponsiveImage() {
return (
<div className="relative">
<div className="overflow-hidden">
<img
src="/images/grid-image/image-01.png"
alt="Cover"
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
export default function ThreeColumnImageGrid() {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
<div>
<img
src="/images/grid-image/image-04.png"
alt=" grid"
className="border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
<div>
<img
src="/images/grid-image/image-05.png"
alt=" grid"
className="border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
<div>
<img
src="/images/grid-image/image-06.png"
alt=" grid"
className="border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
export default function TwoColumnImageGrid() {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<img
src="/images/grid-image/image-02.png"
alt=" grid"
className="border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
<div>
<img
src="/images/grid-image/image-03.png"
alt=" grid"
className="border border-gray-200 rounded-xl dark:border-gray-800"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { ReactNode } from "react";
interface ListProps {
children: ReactNode;
variant?: "unordered" | "ordered" | "horizontal" | "button" | "icon" | "checkbox" | "radio";
className?: string;
}
export const List: React.FC<ListProps> = ({
children,
variant = "unordered",
className = "",
}) => {
const baseClasses = "rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] sm:w-fit";
if (variant === "ordered") {
return (
<ol className={`flex flex-col list-decimal ${baseClasses} ${className}`}>
{children}
</ol>
);
}
if (variant === "horizontal") {
return (
<ul className={`flex flex-col md:flex-row ${baseClasses} ${className}`}>
{children}
</ul>
);
}
if (variant === "button") {
return (
<ul className={`w-full overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] sm:w-[228px] flex flex-col ${className}`}>
{children}
</ul>
);
}
return (
<ul className={`flex flex-col ${baseClasses} ${className}`}>
{children}
</ul>
);
};
interface ListItemProps {
children: ReactNode;
variant?: "unordered" | "ordered" | "horizontal" | "button" | "icon" | "checkbox" | "radio";
onClick?: () => void;
disabled?: boolean;
className?: string;
}
export const ListItem: React.FC<ListItemProps> = ({
children,
variant = "unordered",
onClick,
disabled = false,
className = "",
}) => {
if (variant === "button") {
return (
<li className={`border-b border-gray-200 last:border-b-0 dark:border-gray-800 ${className}`}>
<button
className={`flex w-full items-center gap-3 px-3 py-2.5 text-sm font-medium text-gray-500 hover:bg-brand-50 hover:text-brand-500 dark:text-gray-400 dark:hover:bg-brand-500/[0.12] dark:hover:text-brand-400 ${disabled ? "disabled:opacity-50" : ""}`}
onClick={onClick}
disabled={disabled}
type="button"
>
{children}
</button>
</li>
);
}
if (variant === "horizontal") {
return (
<li className={`flex items-center gap-2 border-b border-gray-200 px-3 py-2.5 text-sm text-gray-500 last:border-0 dark:border-gray-800 dark:text-gray-400 md:border-b-0 md:border-r ${className}`}>
{children}
</li>
);
}
return (
<li className={`flex items-center gap-2 border-b border-gray-200 px-3 py-2.5 text-sm text-gray-500 last:border-b-0 dark:border-gray-800 dark:text-gray-400 ${className}`}>
{children}
</li>
);
};
interface ListDotProps {
className?: string;
}
export const ListDot: React.FC<ListDotProps> = ({ className = "" }) => {
return (
<span className={`ml-2 block h-[3px] w-[3px] rounded-full bg-gray-500 dark:bg-gray-400 ${className}`}></span>
);
};
interface ListIconProps {
children: ReactNode;
className?: string;
}
export const ListIcon: React.FC<ListIconProps> = ({
children,
className = "",
}) => {
return (
<span className={`text-brand-500 dark:text-brand-400 ${className}`}>
{children}
</span>
);
};
interface ListCheckboxItemProps {
id: string;
label: string;
checked?: boolean;
disabled?: boolean;
onChange?: (checked: boolean) => void;
className?: string;
}
export const ListCheckboxItem: React.FC<ListCheckboxItemProps> = ({
id,
label,
checked = false,
disabled = false,
onChange,
className = "",
}) => {
return (
<li className={`border-b border-gray-200 px-3 py-2.5 last:border-b-0 dark:border-gray-800 ${className}`}>
<div className="flex items-center gap-2">
<label className="flex items-center space-x-3 group cursor-pointer">
<div className="relative w-5 h-5">
<input
id={id}
className="w-5 h-5 appearance-none cursor-pointer dark:border-gray-700 border border-gray-300 checked:border-transparent rounded-md checked:bg-brand-500 disabled:opacity-60"
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange?.(e.target.checked)}
/>
</div>
</label>
<label htmlFor={id} className="flex items-center text-sm text-gray-500 cursor-pointer select-none dark:text-gray-400">
{label}
</label>
</div>
</li>
);
};
interface ListRadioItemProps {
id: string;
name: string;
value: string;
label: string;
checked?: boolean;
onChange?: (value: string) => void;
className?: string;
}
export const ListRadioItem: React.FC<ListRadioItemProps> = ({
id,
name,
value,
label,
checked = false,
onChange,
className = "",
}) => {
return (
<li className={`border-b border-gray-200 px-3 py-2.5 last:border-b-0 dark:border-gray-800 ${className}`}>
<label htmlFor={id} className="flex cursor-pointer select-none items-center text-sm text-gray-500 dark:text-gray-400">
<span className="relative">
<input
id={id}
className="sr-only"
type="radio"
value={value}
name={name}
checked={checked}
onChange={(e) => onChange?.(e.target.value)}
/>
<span className={`mr-2 flex h-4 w-4 items-center justify-center rounded-full border ${checked ? "border-brand-500 bg-brand-500" : "bg-transparent border-gray-300 dark:border-gray-700"}`}>
<span className="h-1.5 w-1.5 rounded-full bg-white dark:bg-[#1e2636]"></span>
</span>
</span>
{label}
</label>
</li>
);
};

View File

@@ -0,0 +1,9 @@
export {
List,
ListItem,
ListDot,
ListIcon,
ListCheckboxItem,
ListRadioItem,
} from "./List";

View File

@@ -0,0 +1,94 @@
import { useRef, useEffect } from "react";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
className?: string;
children: React.ReactNode;
showCloseButton?: boolean; // New prop to control close button visibility
isFullscreen?: boolean; // Default to false for backwards compatibility
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
children,
className,
showCloseButton = true, // Default to true for backwards compatibility
isFullscreen = false,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isOpen]);
if (!isOpen) return null;
const contentClasses = isFullscreen
? "w-full h-full"
: "relative w-full rounded-3xl bg-white dark:bg-gray-900";
return (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-99999">
{!isFullscreen && (
<div
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
onClick={onClose}
></div>
)}
<div
ref={modalRef}
className={`${contentClasses} ${className}`}
onClick={(e) => e.stopPropagation()}
>
{showCloseButton && (
<button
onClick={onClose}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
)}
<div>{children}</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,142 @@
import React from "react";
import { AngleLeftIcon, AngleRightIcon } from "../../../icons";
interface CompactPaginationProps {
currentPage: number;
totalPages: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
className?: string;
}
export const CompactPagination: React.FC<CompactPaginationProps> = ({
currentPage,
totalPages,
pageSize,
onPageChange,
onPageSizeChange,
className = "",
}) => {
const getPageNumbers = () => {
const pages: (number | string)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 5; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push("...");
for (let i = totalPages - 4; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push("...");
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
}
}
return pages;
};
const pages = getPageNumbers();
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
return (
<div className={`flex items-center gap-3 ${className}`}>
{/* Page Size Selector */}
<div className="flex items-center gap-2">
<label htmlFor="page-size" className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
Show:
</label>
<select
id="page-size"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-8 px-2 text-sm rounded-lg border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
per page
</span>
</div>
{/* Pagination Controls */}
<div className="flex items-center gap-1">
{/* Previous Button with Icon */}
<button
onClick={() => !isFirstPage && onPageChange(currentPage - 1)}
disabled={isFirstPage}
type="button"
className="flex items-center justify-center w-7 h-7 rounded-lg border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 transition-colors"
aria-label="Previous page"
>
<AngleLeftIcon className="w-4 h-4" />
</button>
{/* Page Numbers */}
<div className="flex items-center gap-0.5">
{pages.map((page, index) => {
if (page === "...") {
return (
<span
key={`ellipsis-${index}`}
className="flex items-center justify-center w-7 h-7 text-xs font-medium text-gray-700 dark:text-gray-400"
>
...
</span>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<button
key={pageNum}
className={`flex items-center justify-center w-7 h-7 text-xs font-medium rounded-lg transition-colors ${
isActive
? "text-white bg-brand-500 hover:bg-brand-600 shadow-sm"
: "text-gray-700 hover:bg-brand-500 hover:text-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-brand-500"
}`}
onClick={() => onPageChange(pageNum)}
type="button"
aria-label={`Go to page ${pageNum}`}
aria-current={isActive ? "page" : undefined}
>
{pageNum}
</button>
);
})}
</div>
{/* Next Button with Icon */}
<button
onClick={() => !isLastPage && onPageChange(currentPage + 1)}
disabled={isLastPage}
type="button"
className="flex items-center justify-center w-7 h-7 rounded-lg border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 transition-colors"
aria-label="Next page"
>
<AngleRightIcon className="w-4 h-4" />
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,139 @@
import { ReactNode } from "react";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
variant?: "text" | "text-icon" | "icon";
className?: string;
showPageInfo?: boolean;
}
const PrevIcon = () => (
<svg className="fill-current" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M2.58203 9.99868C2.58174 10.1909 2.6549 10.3833 2.80152 10.53L7.79818 15.5301C8.09097 15.8231 8.56584 15.8233 8.85883 15.5305C9.15183 15.2377 9.152 14.7629 8.85921 14.4699L5.13911 10.7472L16.6665 10.7472C17.0807 10.7472 17.4165 10.4114 17.4165 9.99715C17.4165 9.58294 17.0807 9.24715 16.6665 9.24715L5.14456 9.24715L8.85919 5.53016C9.15199 5.23717 9.15184 4.7623 8.85885 4.4695C8.56587 4.1767 8.09099 4.17685 7.79819 4.46984L2.84069 9.43049C2.68224 9.568 2.58203 9.77087 2.58203 9.99715C2.58203 9.99766 2.58203 9.99817 2.58203 9.99868Z" fill=""></path>
</svg>
);
const NextIcon = () => (
<svg className="fill-current" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M17.4165 9.9986C17.4168 10.1909 17.3437 10.3832 17.197 10.53L12.2004 15.5301C11.9076 15.8231 11.4327 15.8233 11.1397 15.5305C10.8467 15.2377 10.8465 14.7629 11.1393 14.4699L14.8594 10.7472L3.33203 10.7472C2.91782 10.7472 2.58203 10.4114 2.58203 9.99715C2.58203 9.58294 2.91782 9.24715 3.33203 9.24715L14.854 9.24715L11.1393 5.53016C10.8465 5.23717 10.8467 4.7623 11.1397 4.4695C11.4327 4.1767 11.9075 4.17685 12.2003 4.46984L17.1578 9.43049C17.3163 9.568 17.4165 9.77087 17.4165 9.99715C17.4165 9.99763 17.4165 9.99812 17.4165 9.9986Z" fill=""></path>
</svg>
);
export const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
variant = "text",
className = "",
showPageInfo = true,
}) => {
const getPageNumbers = () => {
const pages: (number | string)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 5; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push("...");
for (let i = totalPages - 4; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push("...");
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
}
}
return pages;
};
const pages = getPageNumbers();
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
const buttonBaseClasses = "rounded-lg border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 sm:px-3.5 sm:py-2.5 disabled:opacity-50 disabled:cursor-not-allowed";
const prevButtonClasses = variant === "icon"
? `${buttonBaseClasses} flex items-center gap-2 p-2 sm:p-2.5`
: `${buttonBaseClasses} flex items-center gap-2`;
const nextButtonClasses = prevButtonClasses;
return (
<div className={`flex items-center justify-between gap-2 px-6 py-4 sm:justify-normal ${className}`}>
<button
className={prevButtonClasses}
onClick={() => !isFirstPage && onPageChange(currentPage - 1)}
disabled={isFirstPage}
type="button"
>
{(variant === "text" || variant === "text-icon") && <span className="inline sm:hidden"><PrevIcon /></span>}
{(variant === "text-icon" || variant === "icon") && <PrevIcon />}
{(variant === "text" || variant === "text-icon") && <span className="hidden sm:inline">{variant === "text-icon" ? " Previous " : "Previous"}</span>}
</button>
{showPageInfo && (
<span className="block text-sm font-medium text-gray-700 dark:text-gray-400 sm:hidden">
Page {currentPage} of {totalPages}
</span>
)}
<ul className="hidden items-center gap-0.5 sm:flex">
{pages.map((page, index) => {
if (page === "...") {
return (
<li key={`ellipsis-${index}`}>
<span className="flex items-center justify-center w-10 h-10 text-sm font-medium text-gray-700 dark:text-gray-400">...</span>
</li>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<li key={pageNum}>
<button
className={`flex items-center justify-center w-10 h-10 text-sm font-medium rounded-lg ${
isActive
? "text-white bg-brand-500 hover:bg-brand-600"
: "text-gray-700 hover:bg-brand-500 hover:text-white dark:text-gray-400 dark:hover:text-white"
}`}
onClick={() => onPageChange(pageNum)}
type="button"
>
{pageNum}
</button>
</li>
);
})}
</ul>
<button
className={nextButtonClasses}
onClick={() => !isLastPage && onPageChange(currentPage + 1)}
disabled={isLastPage}
type="button"
>
{(variant === "text" || variant === "text-icon") && <span className="hidden sm:inline">{variant === "text-icon" ? " Next " : "Next"}</span>}
{(variant === "text-icon" || variant === "icon") && <NextIcon />}
{(variant === "text" || variant === "text-icon") && <span className="inline sm:hidden"><NextIcon /></span>}
</button>
</div>
);
};

View File

@@ -0,0 +1,3 @@
export { Pagination } from "./Pagination";
export { CompactPagination } from "./CompactPagination";

View File

@@ -0,0 +1,417 @@
import { useState } from 'react';
export interface PricingPlan {
id?: number;
name: string;
price: string | number; // Current displayed price (will be calculated based on period)
monthlyPrice?: string | number; // Base monthly price (used for annual discount calculation)
originalPrice?: string | number;
period?: string; // "/month", "/year", "/Lifetime"
description?: string;
features: string[];
buttonText?: string;
highlighted?: boolean; // For featured/popular plan
icon?: React.ReactNode;
disabled?: boolean;
recommended?: boolean; // For "Recommended" badge
}
export interface PricingTableProps {
variant?: '1' | '2' | '3'; // Three different table styles
title?: string;
subtitle?: string;
plans: PricingPlan[];
showToggle?: boolean; // Monthly/Annually toggle
onPlanSelect?: (plan: PricingPlan) => void;
className?: string;
}
// Checkmark SVG Icon
const CheckIcon = () => (
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="text-success-500">
<path d="M13.4017 4.35986L6.12166 11.6399L2.59833 8.11657" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
);
// X Icon for excluded features
const XIcon = () => (
<svg width="1em" height="1em" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="text-gray-400">
<path fillRule="evenodd" clipRule="evenodd" d="M4.05394 4.78033C3.76105 4.48744 3.76105 4.01256 4.05394 3.71967C4.34684 3.42678 4.82171 3.42678 5.1146 3.71967L8.33437 6.93944L11.5521 3.72173C11.845 3.42883 12.3199 3.42883 12.6127 3.72173C12.9056 4.01462 12.9056 4.48949 12.6127 4.78239L9.39503 8.0001L12.6127 11.2178C12.9056 11.5107 12.9056 11.9856 12.6127 12.2785C12.3198 12.5713 11.845 12.5713 11.5521 12.2785L8.33437 9.06076L5.11462 12.2805C4.82173 12.5734 4.34685 12.5734 4.05396 12.2805C3.76107 11.9876 3.76107 11.5127 4.05396 11.2199L7.27371 8.0001L4.05394 4.78033Z" fill="currentColor"></path>
</svg>
);
export default function PricingTable({
variant = '1',
title,
subtitle,
plans,
showToggle = false,
onPlanSelect,
className = '',
}: PricingTableProps) {
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annually'>('monthly');
const handlePlanClick = (plan: PricingPlan) => {
if (plan.disabled) return;
onPlanSelect?.(plan);
};
const formatPrice = (price: string | number) => {
if (typeof price === 'number') {
return price.toFixed(2);
}
return price;
};
// Calculate price based on billing period with 20% annual discount
const getDisplayPrice = (plan: PricingPlan): { price: number; originalPrice?: number } => {
const monthlyPrice = typeof plan.monthlyPrice === 'number'
? plan.monthlyPrice
: typeof plan.price === 'number'
? plan.price
: parseFloat(String(plan.price || 0));
if (billingPeriod === 'annually' && showToggle) {
// Annual price: monthly * 12 * 0.8 (20% discount)
const annualPrice = monthlyPrice * 12 * 0.8;
const originalAnnualPrice = monthlyPrice * 12;
return { price: annualPrice, originalPrice: originalAnnualPrice };
}
// Monthly price
return { price: monthlyPrice };
};
// Variant 1: With toggle and highlighted center card
if (variant === '1') {
return (
<div className={`space-y-6 ${className}`}>
{title && (
<div className="mx-auto w-full max-w-[385px]">
<h2 className="font-bold text-center text-gray-800 mb-7 text-title-sm dark:text-white/90">
{title}
</h2>
</div>
)}
{showToggle && (
<div className="mb-10 text-center">
<div className="relative inline-flex p-1 mx-auto bg-gray-200 rounded-full z-1 dark:bg-gray-800">
<span
className={`absolute top-1/2 -z-1 flex h-11 w-[120px] -translate-y-1/2 rounded-full bg-white shadow-theme-xs duration-200 ease-linear dark:bg-white/10 ${
billingPeriod === 'monthly' ? 'translate-x-0' : 'translate-x-[120px]'
}`}
></span>
<button
onClick={() => setBillingPeriod('monthly')}
className={`flex h-11 w-[120px] items-center justify-center text-base font-medium transition-colors ${
billingPeriod === 'monthly'
? 'text-gray-800 dark:text-white/90'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-white/80 dark:text-gray-400'
}`}
>
Monthly
</button>
<button
onClick={() => setBillingPeriod('annually')}
className={`flex h-11 w-[120px] items-center justify-center text-base font-medium transition-colors ${
billingPeriod === 'annually'
? 'text-gray-800 dark:text-white/90'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-white/80 dark:text-gray-400'
}`}
>
Annually
</button>
</div>
</div>
)}
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:gap-6">
{plans.map((plan, index) => {
const isHighlighted = plan.highlighted || false; // Use explicit highlighted prop
const displayPrice = getDisplayPrice(plan);
const period = billingPeriod === 'annually' && showToggle ? '/year' : (plan.period || '/month');
return (
<div
key={plan.id || index}
className={`rounded-2xl border p-6 flex flex-col ${
isHighlighted
? 'bg-gray-800 border-gray-800 dark:border-white/10 dark:bg-white/10'
: 'border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]'
}`}
>
<span
className={`block mb-3 font-semibold text-theme-xl ${
isHighlighted ? 'text-white' : 'text-gray-800 dark:text-white/90'
}`}
>
{plan.name}
</span>
<div className="flex items-center justify-between mb-1">
<div className="flex items-end">
<h2
className={`font-bold text-title-md ${
isHighlighted ? 'text-white' : 'text-gray-800 dark:text-white/90'
}`}
>
${formatPrice(displayPrice.price)}
</h2>
<span
className={`inline-block mb-1 text-sm ${
isHighlighted ? 'text-white/70' : 'text-gray-500 dark:text-gray-400'
}`}
>
{period}
</span>
</div>
{(displayPrice.originalPrice || plan.originalPrice) && (
<span
className={`font-semibold line-through text-theme-xl ${
isHighlighted ? 'text-gray-300' : 'text-gray-400'
}`}
>
${formatPrice(displayPrice.originalPrice || plan.originalPrice || 0)}
</span>
)}
</div>
{plan.description && (
<p
className={`text-sm ${
isHighlighted ? 'text-white/70' : 'text-gray-500 dark:text-gray-400'
}`}
>
{plan.description}
</p>
)}
<div className={`w-full h-px my-6 ${isHighlighted ? 'bg-white/20' : 'bg-gray-200 dark:bg-gray-800'}`}></div>
<ul className="mb-8 space-y-3 flex-grow">
{plan.features.map((feature, idx) => {
const isExcluded = feature.startsWith('!');
const featureText = isExcluded ? feature.substring(1) : feature;
return (
<li
key={idx}
className={`flex items-center gap-3 text-sm ${
isHighlighted
? 'text-white/80'
: isExcluded
? 'text-gray-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{isExcluded ? <XIcon /> : <CheckIcon />}
{featureText}
</li>
);
})}
</ul>
<button
onClick={() => handlePlanClick(plan)}
disabled={plan.disabled}
className={`flex w-full items-center justify-center rounded-lg p-3.5 text-sm font-medium text-white shadow-theme-xs transition-colors mt-auto ${
isHighlighted
? 'bg-brand-500 hover:bg-brand-600 dark:hover:bg-brand-600'
: 'bg-gray-800 hover:bg-brand-500 dark:bg-white/10 dark:hover:bg-brand-600'
} ${plan.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{plan.buttonText || 'Choose Plan'}
</button>
</div>
);
})}
</div>
</div>
);
}
// Variant 2: With icons and border highlight
if (variant === '2') {
return (
<div className={`space-y-6 ${className}`}>
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{plans.map((plan, index) => {
const isHighlighted = plan.highlighted || index === 1;
return (
<div
key={plan.id || index}
className={`rounded-2xl border p-6 xl:p-8 ${
isHighlighted
? 'border-2 border-brand-500 bg-white dark:border-brand-500 dark:bg-white/[0.03]'
: 'border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]'
}`}
>
<div className="flex items-start justify-between -mb-4">
<span className="block font-semibold text-gray-800 text-theme-xl dark:text-white/90">
{plan.name}
</span>
{plan.icon && (
<span className="flex h-[56px] dark:bg-brand-500/10 w-[56px] items-center justify-center rounded-[10.5px] bg-brand-50 text-brand-500">
{plan.icon}
</span>
)}
</div>
<div className="flex items-end">
<h2 className="font-bold text-gray-800 text-title-md dark:text-white/90">
${formatPrice(plan.price)}
</h2>
<span className="inline-block mb-1 text-sm text-gray-500 dark:text-gray-400">
{plan.period || ' / Lifetime'}
</span>
</div>
{plan.description && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{plan.description}</p>
)}
<div className="w-full h-px my-6 bg-gray-200 dark:bg-gray-800"></div>
<ul className="mb-8 space-y-3">
{plan.features.map((feature, idx) => {
const isExcluded = feature.startsWith('!');
const featureText = isExcluded ? feature.substring(1) : feature;
return (
<li
key={idx}
className={`flex items-center gap-3 text-sm ${
isExcluded ? 'text-gray-400' : 'text-gray-700 dark:text-gray-400'
}`}
>
{isExcluded ? <XIcon /> : <CheckIcon />}
{featureText}
</li>
);
})}
</ul>
<button
onClick={() => handlePlanClick(plan)}
disabled={plan.disabled}
className={`flex w-full items-center justify-center rounded-lg p-3.5 text-sm font-medium text-white shadow-theme-xs transition-colors ${
isHighlighted
? 'bg-brand-500 hover:bg-brand-600'
: 'bg-gray-800 hover:bg-brand-500 dark:bg-white/10 dark:hover:bg-brand-600'
} ${plan.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{plan.buttonText || (isHighlighted ? 'Choose This Plan' : 'Choose Starter')}
</button>
</div>
);
})}
</div>
</div>
);
}
// Variant 3: Compact with recommended badge
if (variant === '3') {
return (
<div className={`space-y-6 ${className}`}>
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 xl:gap-3 2xl:grid-cols-4">
{plans.map((plan, index) => {
const isRecommended = plan.recommended || index === 2;
return (
<div key={plan.id || index}>
<div
className={`rounded-2xl p-6 ${
isRecommended
? 'relative bg-brand-500'
: 'bg-white dark:bg-white/[0.03]'
}`}
>
{isRecommended && (
<div className="absolute px-3 py-1 font-medium text-white rounded-lg right-4 top-4 -z-1 bg-white/10 text-theme-xs">
Recommended
</div>
)}
<span
className={`block font-semibold text-theme-xl ${
isRecommended ? 'text-white' : 'text-gray-800 dark:text-white/90'
}`}
>
{plan.name}
</span>
{plan.description && (
<p
className={`mt-1 text-sm ${
isRecommended ? 'text-white/90' : 'text-gray-500 dark:text-gray-400'
}`}
>
{plan.description}
</p>
)}
<h2
className={`mb-0.5 mt-4 text-title-sm font-bold ${
isRecommended ? 'text-white' : 'text-gray-800 dark:text-white/90'
}`}
>
{typeof plan.price === 'string' && plan.price.toLowerCase() === 'free'
? 'Free'
: `$${formatPrice(plan.price)}`}
</h2>
<span
className={`inline-block mb-6 text-sm ${
isRecommended ? 'text-white/90' : 'text-gray-500 dark:text-gray-400'
}`}
>
{plan.period || 'For a Lifetime'}
</span>
<button
onClick={() => handlePlanClick(plan)}
disabled={plan.disabled}
className={`flex h-11 w-full items-center justify-center rounded-lg p-3.5 text-sm font-medium shadow-theme-xs transition-colors ${
isRecommended
? 'bg-white text-gray-800 hover:bg-gray-50'
: plan.disabled
? 'border border-gray-300 bg-white text-gray-400 disabled:pointer-events-none dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-white/[0.03] dark:hover:text-gray-200'
: 'bg-brand-500 text-white hover:bg-brand-600'
}`}
>
{plan.buttonText || (plan.disabled ? 'Current Plan' : 'Try for Free')}
</button>
<ul className="mt-6 space-y-3">
{plan.features.map((feature, idx) => {
const isExcluded = feature.startsWith('!');
const featureText = isExcluded ? feature.substring(1) : feature;
return (
<li
key={idx}
className={`flex items-center gap-3 text-sm ${
isRecommended
? 'text-white'
: isExcluded
? 'text-gray-400'
: 'text-gray-700 dark:text-gray-400'
}`}
>
{isExcluded ? (
<XIcon />
) : (
<svg
width="1em"
height="1em"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={isRecommended ? 'text-white' : 'text-success-500'}
>
<path
d="M13.4017 4.35986L6.12166 11.6399L2.59833 8.11657"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
)}
{featureText}
</li>
);
})}
</ul>
</div>
</div>
);
})}
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,3 @@
export { default as PricingTable } from './PricingTable';
export type { PricingPlan, PricingTableProps } from './PricingTable';

View File

@@ -0,0 +1,59 @@
import { ReactNode } from "react";
interface ProgressBarProps {
value: number; // 0-100
color?: "primary" | "success" | "error" | "warning" | "info";
size?: "sm" | "md" | "lg";
showLabel?: boolean;
label?: string;
className?: string;
}
export const ProgressBar: React.FC<ProgressBarProps> = ({
value,
color = "primary",
size = "md",
showLabel = false,
label,
className = "",
}) => {
const sizeClasses = {
sm: "h-1",
md: "h-2",
lg: "h-3",
};
const colorClasses = {
primary: "bg-brand-500",
success: "bg-success-500",
error: "bg-error-500",
warning: "bg-warning-500",
info: "bg-blue-light-500",
};
const clampedValue = Math.min(100, Math.max(0, value));
return (
<div className={className}>
{showLabel && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{label || `${clampedValue}%`}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{clampedValue}%
</span>
</div>
)}
<div
className={`w-full rounded-full bg-gray-200 dark:bg-gray-700 ${sizeClasses[size]}`}
>
<div
className={`rounded-full transition-all duration-300 ${sizeClasses[size]} ${colorClasses[color]}`}
style={{ width: `${clampedValue}%` }}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { ProgressBar } from "./ProgressBar";

View File

@@ -0,0 +1,72 @@
import { ReactNode } from "react";
interface RibbonProps {
children: ReactNode;
text: string;
variant?: "rounded" | "filled" | "corner";
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
color?: "primary" | "success" | "error" | "warning";
className?: string;
}
export const Ribbon: React.FC<RibbonProps> = ({
children,
text,
variant = "rounded",
position = "top-left",
color = "primary",
className = "",
}) => {
const colorClasses = {
primary: "bg-brand-500",
success: "bg-success-500",
error: "bg-error-500",
warning: "bg-warning-500",
};
const ribbonContent = () => {
if (variant === "rounded") {
return (
<span
className={`absolute -left-px mt-3 inline-block rounded-r-full ${colorClasses[color]} px-4 py-1.5 text-sm font-medium text-white`}
>
{text}
</span>
);
}
if (variant === "filled") {
return (
<span
className={`absolute -left-9 -top-7 mt-3 flex h-14 w-24 -rotate-45 items-end justify-center ${colorClasses[color]} px-4 py-1.5 text-sm font-medium text-white shadow-theme-xs`}
>
{text}
</span>
);
}
// corner variant - using border colors matching the ribbon color
const borderColorClass = {
primary: "before:border-l-brand-500 before:border-t-brand-500 after:border-b-brand-500 after:border-l-brand-500",
success: "before:border-l-success-500 before:border-t-success-500 after:border-b-success-500 after:border-l-success-500",
error: "before:border-l-error-500 before:border-t-error-500 after:border-b-error-500 after:border-l-error-500",
warning: "before:border-l-warning-500 before:border-t-warning-500 after:border-b-warning-500 after:border-l-warning-500",
};
return (
<span
className={`absolute -left-px mt-3 inline-block ${colorClasses[color]} px-4 py-1.5 text-sm font-medium text-white before:absolute before:-right-4 before:top-0 before:border-[13px] before:border-transparent before:content-[''] after:absolute after:-right-4 after:bottom-0 after:border-[13px] after:border-transparent after:content-[''] ${borderColorClass[color]}`}
>
{text}
</span>
);
};
return (
<div className={`relative overflow-hidden ${className}`}>
{ribbonContent()}
{children}
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { Ribbon } from "./Ribbon";

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,38 @@
import { ReactNode } from "react";
interface SpinnerProps {
size?: "sm" | "md" | "lg";
color?: "primary" | "success" | "error" | "warning" | "info";
className?: string;
}
export const Spinner: React.FC<SpinnerProps> = ({
size = "md",
color = "primary",
className = "",
}) => {
const sizeClasses = {
sm: "h-6 w-6 border-2",
md: "h-10 w-10 border-4",
lg: "h-16 w-16 border-4",
};
const colorClasses = {
primary: "border-gray-200 border-t-brand-500",
success: "border-success-200 border-t-success-500",
error: "border-error-200 border-t-error-500",
warning: "border-warning-200 border-t-warning-500",
info: "border-blue-light-200 border-t-blue-light-500",
};
return (
<div
className={`inline-flex animate-spin items-center justify-center rounded-full ${sizeClasses[size]} ${colorClasses[color]} ${className}`}
role="status"
aria-label="Loading"
>
<span className="sr-only">Loading...</span>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { Spinner } from "./Spinner";

View File

@@ -0,0 +1,70 @@
/**
* Table Components
*
* 🔒 STYLE LOCKED - MUST use .igny8-table-compact class on all tables.
* See DESIGN_SYSTEM.md for complete table styling guidelines.
*/
import { ReactNode } from "react";
// Props for Table
interface TableProps {
children: ReactNode; // Table content (thead, tbody, etc.)
className?: string; // Optional className for styling
}
// Props for TableHeader
interface TableHeaderProps {
children: ReactNode; // Header row(s)
className?: string; // Optional className for styling
}
// Props for TableBody
interface TableBodyProps {
children: ReactNode; // Body row(s)
className?: string; // Optional className for styling
}
// Props for TableRow
interface TableRowProps {
children: ReactNode; // Cells (th or td)
className?: string; // Optional className for styling
}
// Props for TableCell
interface TableCellProps {
children: ReactNode; // Cell content
isHeader?: boolean; // If true, renders as <th>, otherwise <td>
className?: string; // Optional className for styling
}
// Table Component
const Table: React.FC<TableProps> = ({ children, className }) => {
return <table className={`min-w-full w-full ${className}`}>{children}</table>;
};
// TableHeader Component
const TableHeader: React.FC<TableHeaderProps> = ({ children, className }) => {
return <thead className={className}>{children}</thead>;
};
// TableBody Component
const TableBody: React.FC<TableBodyProps> = ({ children, className }) => {
return <tbody className={className}>{children}</tbody>;
};
// TableRow Component
const TableRow: React.FC<TableRowProps> = ({ children, className }) => {
return <tr className={className}>{children}</tr>;
};
// TableCell Component
const TableCell: React.FC<TableCellProps> = ({
children,
isHeader = false,
className,
}) => {
const CellTag = isHeader ? "th" : "td";
return <CellTag className={` ${className}`}>{children}</CellTag>;
};
export { Table, TableHeader, TableBody, TableRow, TableCell };

View File

@@ -0,0 +1,99 @@
import { ReactNode, useState } from "react";
interface TabsProps {
children: ReactNode;
defaultTab?: string;
className?: string;
onChange?: (tabId: string) => void;
}
export const Tabs: React.FC<TabsProps> = ({
children,
defaultTab,
className = "",
onChange,
}) => {
const [activeTab, setActiveTab] = useState(defaultTab || "");
const handleTabChange = (tabId: string) => {
setActiveTab(tabId);
if (onChange) {
onChange(tabId);
}
};
return (
<div className={className}>
{typeof children === "function"
? children(activeTab, handleTabChange)
: children}
</div>
);
};
interface TabListProps {
children: ReactNode;
className?: string;
}
export const TabList: React.FC<TabListProps> = ({
children,
className = "",
}) => {
return (
<div
className={`flex items-center gap-0.5 rounded-lg bg-gray-100 p-0.5 dark:bg-gray-900 ${className}`}
>
{children}
</div>
);
};
interface TabProps {
children: ReactNode;
tabId: string;
isActive?: boolean;
onClick?: () => void;
className?: string;
}
export const Tab: React.FC<TabProps> = ({
children,
tabId,
isActive = false,
onClick,
className = "",
}) => {
return (
<button
onClick={onClick}
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${
isActive
? "shadow-theme-xs text-gray-900 dark:text-white bg-white dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400"
} ${className}`}
type="button"
>
{children}
</button>
);
};
interface TabPanelProps {
children: ReactNode;
tabId: string;
isActive?: boolean;
className?: string;
}
export const TabPanel: React.FC<TabPanelProps> = ({
children,
tabId,
isActive = false,
className = "",
}) => {
if (!isActive) return null;
return <div className={className}>{children}</div>;
};

View File

@@ -0,0 +1,2 @@
export { Tabs, TabList, Tab, TabPanel } from "./Tabs";

View File

@@ -0,0 +1,91 @@
import { useEffect } from 'react';
export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
variant: ToastVariant;
title: string;
message?: string;
}
interface ToastProps extends Toast {
onClose: (id: string) => void;
}
export default function ToastItem({ id, variant, title, message, onClose }: ToastProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose(id);
}, 5000); // Auto-dismiss after 5 seconds
return () => clearTimeout(timer);
}, [id, onClose]);
const variantStyles = {
success: {
container: 'bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-white shadow-lg',
icon: (
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-success-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
),
},
error: {
container: 'bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-white shadow-lg',
icon: (
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-error-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
),
},
warning: {
container: 'bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-white shadow-lg',
icon: (
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-warning-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
),
},
info: {
container: 'bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-white shadow-lg',
icon: (
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-blue-light-500" 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 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
),
},
};
const styles = variantStyles[variant];
return (
<div
className={`flex items-start gap-3 p-4 rounded-lg shadow-sm min-w-[320px] max-w-[420px] animate-slide-in-right ${styles.container}`}
>
{styles.icon}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold mb-0.5">{title}</h4>
{message && <p className="text-sm text-gray-600 dark:text-gray-300">{message}</p>}
</div>
<button
onClick={() => onClose(id)}
className="flex-shrink-0 ml-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { createPortal } from 'react-dom';
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import ToastItem, { Toast, ToastVariant } from './Toast';
interface ToastContextType {
success: (title: string, message?: string) => string;
error: (title: string, message?: string) => string;
warning: (title: string, message?: string) => string;
info: (title: string, message?: string) => string;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
let toastIdCounter = 0;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((variant: ToastVariant, title: string, message?: string) => {
const id = `toast-${++toastIdCounter}`;
const newToast: Toast = { id, variant, title, message };
setToasts((prev) => [...prev, newToast]);
return id;
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const success = useCallback((title: string, message?: string) => {
return showToast('success', title, message);
}, [showToast]);
const error = useCallback((title: string, message?: string) => {
return showToast('error', title, message);
}, [showToast]);
const warning = useCallback((title: string, message?: string) => {
return showToast('warning', title, message);
}, [showToast]);
const info = useCallback((title: string, message?: string) => {
return showToast('info', title, message);
}, [showToast]);
return (
<ToastContext.Provider value={{ success, error, warning, info }}>
{children}
<ToastContainer toasts={toasts} onClose={removeToast} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
interface ToastContainerProps {
toasts: Toast[];
onClose: (id: string) => void;
}
function ToastContainer({ toasts, onClose }: ToastContainerProps) {
if (toasts.length === 0) return null;
return createPortal(
<div className="fixed top-6 right-4 z-[999999] flex flex-col gap-3 pointer-events-none">
{toasts.map((toast, index) => (
<div key={toast.id} className="pointer-events-auto" style={{ animationDelay: `${index * 50}ms` }}>
<ToastItem {...toast} onClose={onClose} />
</div>
))}
</div>,
document.body
);
}

View File

@@ -0,0 +1,36 @@
import { ReactNode } from "react";
interface TooltipProps {
children: ReactNode;
text: string;
placement?: "top" | "bottom" | "left" | "right";
className?: string;
}
export const Tooltip: React.FC<TooltipProps> = ({
children,
text,
placement = "top",
className = "",
}) => {
const placementClasses = {
top: "bottom-full left-1/2 mb-2 -translate-x-1/2 before:top-full before:left-1/2 before:-translate-x-1/2 before:-mt-1 before:border-t-gray-900",
bottom:
"top-full left-1/2 mt-2 -translate-x-1/2 before:bottom-full before:left-1/2 before:-translate-x-1/2 before:-mb-1 before:border-b-gray-900",
left: "right-full top-1/2 mr-2 -translate-y-1/2 before:left-full before:top-1/2 before:-translate-y-1/2 before:-ml-1 before:border-l-gray-900",
right:
"left-full top-1/2 ml-2 -translate-y-1/2 before:right-full before:top-1/2 before:-translate-y-1/2 before:-mr-1 before:border-r-gray-900",
};
return (
<div className={`relative group inline-flex ${className}`}>
{children}
<span
className={`absolute px-3 py-1.5 text-xs font-medium text-white bg-gray-900 rounded-lg opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity whitespace-nowrap before:absolute before:border-4 before:border-transparent before:content-[''] ${placementClasses[placement]}`}
>
{text}
</span>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { Tooltip } from "./Tooltip";

View File

@@ -0,0 +1,26 @@
type AspectRatioVideoProps = {
videoUrl: string; // URL of the video
aspectRatio?: string; // Aspect ratio in the format "width/height", default is "16/9"
title?: string; // Video title, default is "Embedded Video"
};
const AspectRatioVideo: React.FC<AspectRatioVideoProps> = ({
videoUrl,
aspectRatio = "video", // Default aspect ratio
title = "Embedded Video",
}) => {
return (
<div className={`aspect-${aspectRatio} overflow-hidden rounded-lg`}>
<iframe
src={videoUrl}
title={title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
></iframe>
</div>
);
};
export default AspectRatioVideo;

View File

@@ -0,0 +1,14 @@
export default function FourIsToThree() {
return (
<div className="aspect-4/3 overflow-hidden rounded-lg">
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
></iframe>
</div>
);
}

View File

@@ -0,0 +1,14 @@
export default function OneIsToOne() {
return (
<div className="overflow-hidden rounded-lg aspect-square">
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
></iframe>
</div>
);
}

View File

@@ -0,0 +1,14 @@
export default function SixteenIsToNine() {
return (
<div className="aspect-4/3 overflow-hidden rounded-lg">
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
></iframe>
</div>
);
}

View File

@@ -0,0 +1,14 @@
export default function TwentyOneIsToNine() {
return (
<div className="aspect-21/9 overflow-hidden rounded-lg">
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
></iframe>
</div>
);
}