feat: add domains, ideas, layouts, widgets API modules
- Add DomainsModule with full CRUD, search, and activity logging - Add IdeasModule with quick capture endpoint - Add LayoutsModule for user dashboard layouts - Add WidgetsModule for widget definitions (read-only) - Update ActivityService with domain/idea logging methods - Register all new modules in AppModule
This commit is contained in:
67
packages/ui/src/components/Avatar.tsx
Normal file
67
packages/ui/src/components/Avatar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ImgHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface AvatarProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "size"> {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
fallback?: ReactNode;
|
||||
initials?: string;
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
src,
|
||||
alt = "User avatar",
|
||||
size = "md",
|
||||
fallback,
|
||||
initials,
|
||||
className = "",
|
||||
...props
|
||||
}: AvatarProps) {
|
||||
const sizeStyles = {
|
||||
sm: "w-6 h-6 text-xs",
|
||||
md: "w-8 h-8 text-sm",
|
||||
lg: "w-12 h-12 text-base",
|
||||
xl: "w-16 h-16 text-xl",
|
||||
};
|
||||
|
||||
const baseStyles = "rounded-full overflow-hidden flex items-center justify-center bg-gray-200 font-medium text-gray-600";
|
||||
|
||||
const combinedClassName = [baseStyles, sizeStyles[size], className].filter(Boolean).join(" ");
|
||||
|
||||
if (src) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`${combinedClassName} object-cover`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
return <div className={combinedClassName}>{fallback}</div>;
|
||||
}
|
||||
|
||||
if (initials) {
|
||||
return <div className={combinedClassName}>{initials}</div>;
|
||||
}
|
||||
|
||||
// Default fallback with user icon
|
||||
return (
|
||||
<div className={combinedClassName}>
|
||||
<svg
|
||||
className="w-1/2 h-1/2 text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
packages/ui/src/components/Badge.tsx
Normal file
30
packages/ui/src/components/Badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type BadgeVariant = "priority-high" | "priority-medium" | "priority-low" | "status-success" | "status-warning" | "status-error" | "status-info" | "status-neutral";
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
"priority-high": "bg-red-100 text-red-800 border-red-200",
|
||||
"priority-medium": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"priority-low": "bg-green-100 text-green-800 border-green-200",
|
||||
"status-success": "bg-green-100 text-green-800 border-green-200",
|
||||
"status-warning": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"status-error": "bg-red-100 text-red-800 border-red-200",
|
||||
"status-info": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
"status-neutral": "bg-gray-100 text-gray-800 border-gray-200",
|
||||
};
|
||||
|
||||
export function Badge({ variant = "status-neutral", children, className = "", ...props }: BadgeProps) {
|
||||
const baseStyles = "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border";
|
||||
const combinedClassName = [baseStyles, variantStyles[variant], className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<span className={combinedClassName} role="status" aria-label={children as string} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function Button({
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700",
|
||||
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 border border-gray-300",
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
|
||||
57
packages/ui/src/components/Card.tsx
Normal file
57
packages/ui/src/components/Card.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
}
|
||||
|
||||
export interface CardHeaderProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface CardContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface CardFooterProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ children, className = "", id, onMouseEnter, onMouseLeave }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={`bg-white rounded-lg shadow-md border border-gray-200 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className = "" }: CardHeaderProps) {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ children, className = "" }: CardContentProps) {
|
||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className = "" }: CardFooterProps) {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
packages/ui/src/components/Input.tsx
Normal file
58
packages/ui/src/components/Input.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = false,
|
||||
className = "",
|
||||
id,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const errorId = error ? `${inputId}-error` : undefined;
|
||||
const helperId = helperText ? `${inputId}-helper` : undefined;
|
||||
|
||||
const baseStyles = "px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||
|
||||
const combinedClassName = [baseStyles, widthStyles, errorStyles, className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className={fullWidth ? "w-full" : ""}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
className={combinedClassName}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
packages/ui/src/components/Modal.tsx
Normal file
121
packages/ui/src/components/Modal.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, type ReactNode, type HTMLAttributes } from "react";
|
||||
|
||||
export interface ModalProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
footer?: ReactNode;
|
||||
closeOnOverlayClick?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
size?: "sm" | "md" | "lg" | "xl" | "full";
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
footer,
|
||||
closeOnOverlayClick = true,
|
||||
closeOnEscape = true,
|
||||
size = "md",
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const modalId = useRef(`modal-${Math.random().toString(36).substr(2, 9)}`);
|
||||
|
||||
const sizeStyles = {
|
||||
sm: "max-w-md",
|
||||
md: "max-w-lg",
|
||||
lg: "max-w-2xl",
|
||||
xl: "max-w-4xl",
|
||||
full: "max-w-full mx-4",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (closeOnEscape && event.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden";
|
||||
// Focus the modal when opened
|
||||
dialogRef.current?.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen, closeOnEscape, onClose]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (closeOnOverlayClick && event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
||||
onClick={handleOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? `${modalId.current}-title` : undefined}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
tabIndex={-1}
|
||||
className={`bg-white rounded-lg shadow-xl w-full ${sizeStyles[size]} ${className}`}
|
||||
role="document"
|
||||
>
|
||||
{title && (
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2
|
||||
id={`${modalId.current}-title`}
|
||||
className="text-lg font-semibold text-gray-900"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
||||
{footer && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg flex justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
packages/ui/src/components/Select.tsx
Normal file
81
packages/ui/src/components/Select.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { SelectHTMLAttributes } from "react";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
fullWidth?: boolean;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = false,
|
||||
options,
|
||||
placeholder = "Select an option...",
|
||||
className = "",
|
||||
id,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const errorId = error ? `${selectId}-error` : undefined;
|
||||
const helperId = helperText ? `${selectId}-helper` : undefined;
|
||||
|
||||
const baseStyles = "px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors bg-white";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||
|
||||
const combinedClassName = [baseStyles, widthStyles, errorStyles, className].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className={fullWidth ? "w-full" : ""}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={combinedClassName}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||
{...props}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
packages/ui/src/components/Textarea.tsx
Normal file
72
packages/ui/src/components/Textarea.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { TextareaHTMLAttributes } from "react";
|
||||
|
||||
export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
fullWidth?: boolean;
|
||||
resize?: "none" | "both" | "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = false,
|
||||
resize = "vertical",
|
||||
className = "",
|
||||
id,
|
||||
...props
|
||||
}: TextareaProps) {
|
||||
const textareaId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const errorId = error ? `${textareaId}-error` : undefined;
|
||||
const helperId = helperText ? `${textareaId}-helper` : undefined;
|
||||
|
||||
const baseStyles = "px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
const resizeStyles = {
|
||||
none: "resize-none",
|
||||
both: "resize",
|
||||
horizontal: "resize-x",
|
||||
vertical: "resize-y",
|
||||
};
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||
|
||||
const combinedClassName = [
|
||||
baseStyles,
|
||||
widthStyles,
|
||||
resizeStyles[resize],
|
||||
errorStyles,
|
||||
className,
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className={fullWidth ? "w-full" : ""}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={textareaId}
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
id={textareaId}
|
||||
className={combinedClassName}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
packages/ui/src/components/Toast.tsx
Normal file
188
packages/ui/src/components/Toast.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
type HTMLAttributes,
|
||||
} from "react";
|
||||
|
||||
export type ToastVariant = "success" | "error" | "warning" | "info";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
variant?: ToastVariant;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface ToastContextValue {
|
||||
showToast: (message: string, variant?: ToastVariant, duration?: number) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, variant: ToastVariant = "info", duration: number = 5000) => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const newToast: Toast = { id, message, variant, duration };
|
||||
|
||||
setToasts((prev) => [...prev, newToast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
},
|
||||
[removeToast]
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToastContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
toasts: Toast[];
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
function ToastContainer({ toasts, onRemove, className = "" }: ToastContainerProps) {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 z-50 flex flex-col gap-2 ${className}`}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastItemProps {
|
||||
toast: Toast;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onRemove }: ToastItemProps) {
|
||||
const variantStyles: Record<ToastVariant, string> = {
|
||||
success: "bg-green-500 text-white border-green-600",
|
||||
error: "bg-red-500 text-white border-red-600",
|
||||
warning: "bg-yellow-500 text-white border-yellow-600",
|
||||
info: "bg-blue-500 text-white border-blue-600",
|
||||
};
|
||||
|
||||
const icon: Record<ToastVariant, ReactNode> = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<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>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<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>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<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>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<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>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${variantStyles[toast.variant || "info"]} border rounded-md shadow-lg px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md`}
|
||||
role="alert"
|
||||
>
|
||||
<span className="flex-shrink-0">{icon[toast.variant || "info"]}</span>
|
||||
<span className="flex-1 text-sm font-medium">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-white/20"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to show toasts outside of React components
|
||||
let toastContextValue: ToastContextValue | null = null;
|
||||
|
||||
export function setToastContext(context: ToastContextValue | null) {
|
||||
toastContextValue = context;
|
||||
}
|
||||
|
||||
export interface ToastOptions {
|
||||
variant?: ToastVariant;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export function toast(message: string, options?: ToastOptions) {
|
||||
if (!toastContextValue) {
|
||||
console.warn("Toast context not available. Make sure ToastProvider is mounted.");
|
||||
return;
|
||||
}
|
||||
toastContextValue.showToast(
|
||||
message,
|
||||
options?.variant || "info",
|
||||
options?.duration || 5000
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,35 @@
|
||||
// Button
|
||||
export { Button } from "./components/Button.js";
|
||||
export type { ButtonProps } from "./components/Button.js";
|
||||
|
||||
// Card
|
||||
export { Card, CardHeader, CardContent, CardFooter } from "./components/Card.js";
|
||||
export type { CardProps, CardHeaderProps, CardContentProps, CardFooterProps } from "./components/Card.js";
|
||||
|
||||
// Badge
|
||||
export { Badge } from "./components/Badge.js";
|
||||
export type { BadgeProps, BadgeVariant } from "./components/Badge.js";
|
||||
|
||||
// Input
|
||||
export { Input } from "./components/Input.js";
|
||||
export type { InputProps } from "./components/Input.js";
|
||||
|
||||
// Textarea
|
||||
export { Textarea } from "./components/Textarea.js";
|
||||
export type { TextareaProps } from "./components/Textarea.js";
|
||||
|
||||
// Avatar
|
||||
export { Avatar } from "./components/Avatar.js";
|
||||
export type { AvatarProps } from "./components/Avatar.js";
|
||||
|
||||
// Select
|
||||
export { Select } from "./components/Select.js";
|
||||
export type { SelectProps, SelectOption } from "./components/Select.js";
|
||||
|
||||
// Modal
|
||||
export { Modal } from "./components/Modal.js";
|
||||
export type { ModalProps } from "./components/Modal.js";
|
||||
|
||||
// Toast
|
||||
export { ToastProvider, useToast, toast } from "./components/Toast.js";
|
||||
export type { Toast, ToastVariant, ToastContextValue, ToastProviderProps } from "./components/Toast.js";
|
||||
|
||||
Reference in New Issue
Block a user