ConfirmModal.tsx 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import { useEffect } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { AlertTriangle, Loader2 } from 'lucide-react';
  4. import { Card, CardContent } from './Card';
  5. import { Button } from './Button';
  6. interface ConfirmModalProps {
  7. title: string;
  8. message: string;
  9. confirmText?: string;
  10. cancelText?: string;
  11. cancelVariant?: 'primary' | 'secondary' | 'danger' | 'ghost';
  12. cardClassName?: string;
  13. variant?: 'danger' | 'warning' | 'default';
  14. isLoading?: boolean;
  15. loadingText?: string;
  16. onConfirm: () => void;
  17. onCancel: () => void;
  18. // Optional slot rendered between the message and the buttons — for confirms
  19. // that need a checkbox or extra inline form (e.g. "also delete archives").
  20. children?: React.ReactNode;
  21. }
  22. export function ConfirmModal({
  23. title,
  24. message,
  25. confirmText,
  26. cancelText,
  27. cancelVariant,
  28. cardClassName,
  29. variant = 'default',
  30. isLoading = false,
  31. loadingText,
  32. onConfirm,
  33. onCancel,
  34. children,
  35. }: ConfirmModalProps) {
  36. const { t } = useTranslation();
  37. const resolvedConfirmText = confirmText ?? t('common.confirm');
  38. const resolvedCancelText = cancelText ?? t('common.cancel');
  39. const resolvedLoadingText = loadingText ?? t('common.loading');
  40. // Close on Escape key (but not while loading)
  41. useEffect(() => {
  42. const handleKeyDown = (e: KeyboardEvent) => {
  43. if (e.key === 'Escape' && !isLoading) onCancel();
  44. };
  45. window.addEventListener('keydown', handleKeyDown);
  46. return () => window.removeEventListener('keydown', handleKeyDown);
  47. }, [onCancel, isLoading]);
  48. const variantStyles = {
  49. danger: {
  50. icon: 'text-red-400',
  51. button: 'bg-red-500 hover:bg-red-600',
  52. },
  53. warning: {
  54. icon: 'text-yellow-400',
  55. button: 'bg-yellow-500 hover:bg-yellow-600 text-black',
  56. },
  57. default: {
  58. icon: 'text-bambu-green',
  59. button: 'bg-bambu-green hover:bg-bambu-green-dark',
  60. },
  61. };
  62. const styles = variantStyles[variant];
  63. return (
  64. <div
  65. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  66. onClick={isLoading ? undefined : onCancel}
  67. >
  68. <Card
  69. className={`w-full max-w-md ${cardClassName ?? ''}`}
  70. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  71. >
  72. <CardContent className="p-6">
  73. <div className="flex items-start gap-4">
  74. <div className={`p-2 rounded-full bg-bambu-dark ${styles.icon}`}>
  75. <AlertTriangle className="w-6 h-6" />
  76. </div>
  77. <div className="flex-1">
  78. <h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
  79. <p className="text-bambu-gray text-sm whitespace-pre-line">{message}</p>
  80. </div>
  81. </div>
  82. {children !== undefined && <div className="mt-4">{children}</div>}
  83. <div className="flex gap-3 mt-6">
  84. <Button
  85. variant={cancelVariant ?? 'secondary'}
  86. onClick={onCancel}
  87. className="flex-1"
  88. disabled={isLoading}
  89. >
  90. {resolvedCancelText}
  91. </Button>
  92. <Button
  93. onClick={onConfirm}
  94. className={`flex-1 ${styles.button}`}
  95. disabled={isLoading}
  96. >
  97. {isLoading ? (
  98. <>
  99. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  100. {resolvedLoadingText}
  101. </>
  102. ) : (
  103. resolvedConfirmText
  104. )}
  105. </Button>
  106. </div>
  107. </CardContent>
  108. </Card>
  109. </div>
  110. );
  111. }