diff --git a/client/src/components/ui/Badge.tsx b/client/src/components/ui/Badge.tsx new file mode 100644 index 0000000..89c91d5 --- /dev/null +++ b/client/src/components/ui/Badge.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { cn } from "../../lib/utils"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type BadgeVariant = + | "default" + | "primary" + | "success" + | "warning" + | "danger" + | "outline"; + +export interface BadgeProps extends React.HTMLAttributes { + variant?: BadgeVariant; + /** Show a colored dot before the label */ + dot?: boolean; +} + +/* ------------------------------------------------------------------ */ +/* Styles */ +/* ------------------------------------------------------------------ */ + +const variantClasses: Record = { + default: "bg-gray-100 text-gray-700", + primary: "bg-blue-100 text-blue-700", + success: "bg-green-100 text-green-700", + warning: "bg-yellow-100 text-yellow-700", + danger: "bg-red-100 text-red-700", + outline: "border border-gray-300 bg-transparent text-gray-700", +}; + +const dotVariantClasses: Record = { + default: "bg-gray-400", + primary: "bg-blue-500", + success: "bg-green-500", + warning: "bg-yellow-500", + danger: "bg-red-500", + outline: "bg-gray-400", +}; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function Badge({ + variant = "default", + dot = false, + className, + children, + ...props +}: BadgeProps) { + return ( + + {dot && ( + + ); +} diff --git a/client/src/components/ui/Button.tsx b/client/src/components/ui/Button.tsx new file mode 100644 index 0000000..9cbbc77 --- /dev/null +++ b/client/src/components/ui/Button.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { cn } from "../../lib/utils"; + +export type ButtonVariant = + | "primary" + | "secondary" + | "ghost" + | "danger" + | "outline"; +export type ButtonSize = "sm" | "md" | "lg"; + +export interface ButtonProps + extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + loading?: boolean; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; +} + +const variantClasses: Record = { + primary: + "bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500", + secondary: + "bg-gray-200 text-gray-900 hover:bg-gray-300 focus-visible:ring-gray-400", + ghost: + "bg-transparent text-gray-700 hover:bg-gray-100 focus-visible:ring-gray-400", + danger: + "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500", + outline: + "border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 focus-visible:ring-gray-400", +}; + +const sizeClasses: Record = { + sm: "h-8 px-3 text-sm", + md: "h-10 px-4 text-sm", + lg: "h-12 px-6 text-base", +}; + +export const Button = React.forwardRef( + ( + { + variant = "primary", + size = "md", + loading = false, + leftIcon, + rightIcon, + children, + disabled, + className, + ...props + }, + ref + ) => { + const isDisabled = disabled || loading; + + return ( + + ); + } +); + +Button.displayName = "Button"; diff --git a/client/src/components/ui/Card.tsx b/client/src/components/ui/Card.tsx new file mode 100644 index 0000000..7bc677a --- /dev/null +++ b/client/src/components/ui/Card.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { cn } from "../../lib/utils"; + +/* ------------------------------------------------------------------ */ +/* Card */ +/* ------------------------------------------------------------------ */ + +export interface CardProps extends React.HTMLAttributes { + /** Remove default padding */ + noPadding?: boolean; +} + +export function Card({ noPadding = false, className, children, ...props }: CardProps) { + return ( +
+ {children} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Card.Header */ +/* ------------------------------------------------------------------ */ + +export interface CardHeaderProps extends React.HTMLAttributes {} + +export function CardHeader({ className, children, ...props }: CardHeaderProps) { + return ( +
+ {children} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Card.Body */ +/* ------------------------------------------------------------------ */ + +export interface CardBodyProps extends React.HTMLAttributes {} + +export function CardBody({ className, children, ...props }: CardBodyProps) { + return ( +
+ {children} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Card.Footer */ +/* ------------------------------------------------------------------ */ + +export interface CardFooterProps extends React.HTMLAttributes {} + +export function CardFooter({ className, children, ...props }: CardFooterProps) { + return ( +
+ {children} +
+ ); +} diff --git a/client/src/components/ui/Input.tsx b/client/src/components/ui/Input.tsx new file mode 100644 index 0000000..175b7e7 --- /dev/null +++ b/client/src/components/ui/Input.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { cn } from "../../lib/utils"; + +export type InputSize = "sm" | "md" | "lg"; + +export interface InputProps + extends Omit, "size"> { + label?: string; + hint?: string; + error?: string; + size?: InputSize; + leftAddon?: React.ReactNode; + rightAddon?: React.ReactNode; +} + +const sizeClasses: Record = { + sm: "h-8 px-2 text-sm", + md: "h-10 px-3 text-sm", + lg: "h-12 px-4 text-base", +}; + +export const Input = React.forwardRef( + ( + { + label, + hint, + error, + size = "md", + leftAddon, + rightAddon, + id, + className, + ...props + }, + ref + ) => { + // Always generate a stable fallback ID so aria-describedby values are never + // built from "undefined" even when neither `id` nor `label` is provided. + const generatedId = React.useId(); + const inputId = id ?? generatedId; + + const hintId = hint ? `${inputId}-hint` : undefined; + const errorId = error ? `${inputId}-error` : undefined; + const describedBy = + [hintId, errorId].filter(Boolean).join(" ") || undefined; + + return ( +
+ {label && ( + + )} + +
+ {leftAddon && ( + + {leftAddon} + + )} + + + + {rightAddon && ( + + {rightAddon} + + )} +
+ + {error && ( + + )} + {hint && !error && ( +

+ {hint} +

+ )} +
+ ); + } +); + +Input.displayName = "Input"; diff --git a/client/src/components/ui/Modal.tsx b/client/src/components/ui/Modal.tsx new file mode 100644 index 0000000..c2fd57e --- /dev/null +++ b/client/src/components/ui/Modal.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { cn } from "../../lib/utils"; + +export interface ModalProps { + open: boolean; + onClose: () => void; + title?: string; + description?: string; + children: React.ReactNode; + className?: string; +} + +export function Modal({ + open, + onClose, + title, + description, + children, + className, +}: ModalProps) { + const titleId = React.useId(); + const descId = React.useId(); + + // Scroll lock + React.useEffect(() => { + if (!open) return; + const previous = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previous; + }; + }, [open]); + + // Escape key handler + React.useEffect(() => { + if (!open) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ {/* Backdrop */} + + ); +} diff --git a/client/src/components/ui/Select.tsx b/client/src/components/ui/Select.tsx new file mode 100644 index 0000000..1a67534 --- /dev/null +++ b/client/src/components/ui/Select.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { cn } from "../../lib/utils"; + +export type SelectSize = "sm" | "md" | "lg"; + +export interface SelectOption { + value: string; + label: string; + disabled?: boolean; +} + +export interface SelectProps + extends Omit, "size"> { + label?: string; + hint?: string; + error?: string; + size?: SelectSize; + options: SelectOption[]; + placeholder?: string; +} + +const sizeClasses: Record = { + sm: "h-8 px-2 text-sm", + md: "h-10 px-3 text-sm", + lg: "h-12 px-4 text-base", +}; + +export const Select = React.forwardRef( + ( + { + label, + hint, + error, + size = "md", + options, + placeholder, + id, + className, + ...props + }, + ref + ) => { + const generatedId = React.useId(); + const selectId = id ?? generatedId; + + const hintId = hint ? `${selectId}-hint` : undefined; + const errorId = error ? `${selectId}-error` : undefined; + const describedBy = + [hintId, errorId].filter(Boolean).join(" ") || undefined; + + return ( +
+ {label && ( + + )} + + + + {error && ( + + )} + {hint && !error && ( +

+ {hint} +

+ )} +
+ ); + } +); + +Select.displayName = "Select"; diff --git a/client/src/components/ui/Skeleton.tsx b/client/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..7635c1b --- /dev/null +++ b/client/src/components/ui/Skeleton.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { cn } from "../../lib/utils"; + +/* ------------------------------------------------------------------ */ +/* Base Skeleton */ +/* ------------------------------------------------------------------ */ + +export interface SkeletonProps extends React.HTMLAttributes { + /** Explicit width (Tailwind class or inline style via `style` prop) */ + width?: string; + /** Explicit height (Tailwind class or inline style via `style` prop) */ + height?: string; + /** Round the skeleton into a circle (for avatars) */ + circle?: boolean; +} + +export function Skeleton({ + width, + height, + circle = false, + className, + style, + ...props +}: SkeletonProps) { + return ( +