ConfirmModal.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { useEffect, type ReactNode } 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. // Tailwind z-index utility applied to the fixed overlay. Defaults to
  14. // ``z-50``. Use a higher value (e.g. ``z-[110]``) when this confirm
  15. // dialog is rendered from inside another modal that uses ``z-[100]`` —
  16. // without it the confirm dialog sits behind its parent (#1336 follow-up).
  17. overlayZIndex?: string;
  18. variant?: 'danger' | 'warning' | 'default';
  19. isLoading?: boolean;
  20. loadingText?: string;
  21. // Disable the confirm button without a loading spinner. Used when an
  22. // external precondition forbids the action (e.g. #1734 — a related queue
  23. // item is mid-print, so the archive delete must be blocked at the UI
  24. // layer too even though the backend will 409 anyway).
  25. confirmDisabled?: boolean;
  26. // Optional extra content rendered between the message and the buttons —
  27. // used for opt-in checkboxes (e.g. the "Also remove from statistics"
  28. // toggle in the archive delete confirmation, #1343).
  29. children?: ReactNode;
  30. onConfirm: () => void;
  31. onCancel: () => void;
  32. }
  33. export function ConfirmModal({
  34. title,
  35. message,
  36. confirmText,
  37. cancelText,
  38. cancelVariant,
  39. cardClassName,
  40. overlayZIndex,
  41. variant = 'default',
  42. isLoading = false,
  43. loadingText,
  44. confirmDisabled = false,
  45. children,
  46. onConfirm,
  47. onCancel,
  48. }: ConfirmModalProps) {
  49. const { t } = useTranslation();
  50. const resolvedConfirmText = confirmText ?? t('common.confirm');
  51. const resolvedCancelText = cancelText ?? t('common.cancel');
  52. const resolvedLoadingText = loadingText ?? t('common.loading');
  53. // Close on Escape key (but not while loading)
  54. useEffect(() => {
  55. const handleKeyDown = (e: KeyboardEvent) => {
  56. if (e.key === 'Escape' && !isLoading) onCancel();
  57. };
  58. window.addEventListener('keydown', handleKeyDown);
  59. return () => window.removeEventListener('keydown', handleKeyDown);
  60. }, [onCancel, isLoading]);
  61. const variantStyles = {
  62. danger: {
  63. icon: 'text-red-600 dark:text-red-400',
  64. button: 'bg-red-500 hover:bg-red-600',
  65. },
  66. warning: {
  67. icon: 'text-yellow-600 dark:text-yellow-400',
  68. button: 'bg-yellow-500 hover:bg-yellow-600 text-black',
  69. },
  70. default: {
  71. icon: 'text-bambu-green',
  72. button: 'bg-bambu-green hover:bg-bambu-green-dark',
  73. },
  74. };
  75. const styles = variantStyles[variant];
  76. return (
  77. <div
  78. className={`fixed inset-0 bg-black/50 flex items-center justify-center p-4 ${overlayZIndex ?? 'z-50'}`}
  79. onClick={isLoading ? undefined : onCancel}
  80. >
  81. <Card
  82. className={`w-full max-w-md ${cardClassName ?? ''}`}
  83. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  84. >
  85. <CardContent className="p-6">
  86. <div className="flex items-start gap-4">
  87. <div className={`p-2 rounded-full bg-bambu-dark ${styles.icon}`}>
  88. <AlertTriangle className="w-6 h-6" />
  89. </div>
  90. <div className="flex-1">
  91. <h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
  92. <p className="text-bambu-gray text-sm whitespace-pre-line">{message}</p>
  93. {children && <div className="mt-4">{children}</div>}
  94. </div>
  95. </div>
  96. <div className="flex gap-3 mt-6">
  97. <Button
  98. variant={cancelVariant ?? 'secondary'}
  99. onClick={onCancel}
  100. className="flex-1"
  101. disabled={isLoading}
  102. >
  103. {resolvedCancelText}
  104. </Button>
  105. <Button
  106. onClick={onConfirm}
  107. className={`flex-1 ${styles.button}`}
  108. disabled={isLoading || confirmDisabled}
  109. >
  110. {isLoading ? (
  111. <>
  112. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  113. {resolvedLoadingText}
  114. </>
  115. ) : (
  116. resolvedConfirmText
  117. )}
  118. </Button>
  119. </div>
  120. </CardContent>
  121. </Card>
  122. </div>
  123. );
  124. }