PurgeArchivesModal.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. import { useEffect, useState } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import { Button } from './Button';
  7. import { useToast } from '../contexts/ToastContext';
  8. import { formatFileSize } from '../utils/file';
  9. interface PurgeArchivesModalProps {
  10. onClose: () => void;
  11. initialDays?: number;
  12. }
  13. const DEFAULT_DAYS = 365;
  14. export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalProps) {
  15. const { t } = useTranslation();
  16. const queryClient = useQueryClient();
  17. const { showToast } = useToast();
  18. const [days, setDays] = useState(initialDays ?? DEFAULT_DAYS);
  19. // #1390: matches the single-archive delete dialog's "Also remove from
  20. // statistics" checkbox. Default off — soft-delete, Quick Stats preserved.
  21. const [purgeStats, setPurgeStats] = useState(false);
  22. const [debouncedDays, setDebouncedDays] = useState(days);
  23. useEffect(() => {
  24. const handle = window.setTimeout(() => setDebouncedDays(days), 300);
  25. return () => window.clearTimeout(handle);
  26. }, [days]);
  27. const previewQuery = useQuery({
  28. queryKey: ['archive-purge-preview', debouncedDays, purgeStats],
  29. queryFn: () => api.previewArchivePurge(debouncedDays, purgeStats),
  30. enabled: debouncedDays >= 1,
  31. });
  32. const purgeMutation = useMutation({
  33. mutationFn: () => api.executeArchivePurge(days, purgeStats),
  34. onSuccess: (res) => {
  35. showToast(t('archivePurge.toast.success', { count: res.deleted }), 'success');
  36. queryClient.invalidateQueries({ queryKey: ['archives'] });
  37. queryClient.invalidateQueries({ queryKey: ['archive-stats'] });
  38. onClose();
  39. },
  40. onError: (e: Error) => showToast(e.message || t('archivePurge.toast.failed'), 'error'),
  41. });
  42. useEffect(() => {
  43. const handleKey = (e: KeyboardEvent) => {
  44. if (e.key === 'Escape' && !purgeMutation.isPending) onClose();
  45. };
  46. window.addEventListener('keydown', handleKey);
  47. return () => window.removeEventListener('keydown', handleKey);
  48. }, [onClose, purgeMutation.isPending]);
  49. const preview = previewQuery.data;
  50. const count = preview?.count ?? 0;
  51. const totalBytes = preview?.total_bytes ?? 0;
  52. const canConfirm = count > 0 && !purgeMutation.isPending;
  53. return (
  54. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
  55. <div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-lg w-full">
  56. <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
  57. <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
  58. <Trash2 className="w-5 h-5" />
  59. {t('archivePurge.title')}
  60. </h2>
  61. <button
  62. onClick={onClose}
  63. className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
  64. aria-label={t('common.close')}
  65. disabled={purgeMutation.isPending}
  66. >
  67. <X className="w-5 h-5" />
  68. </button>
  69. </div>
  70. <div className="p-4 space-y-4">
  71. <p className="text-sm text-gray-600 dark:text-gray-400">
  72. {t('archivePurge.description')}
  73. </p>
  74. <div>
  75. <label htmlFor="archive-purge-days" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
  76. {t('archivePurge.ageLabel')}
  77. </label>
  78. <div className="flex items-center gap-3">
  79. <input
  80. id="archive-purge-days"
  81. type="number"
  82. min={1}
  83. max={3650}
  84. value={days}
  85. onChange={(e) => setDays(Math.max(1, Math.min(3650, parseInt(e.target.value || '0', 10) || 0)))}
  86. className="w-24 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm px-2 py-1 text-gray-900 dark:text-gray-100"
  87. />
  88. <span className="text-sm text-gray-600 dark:text-gray-400">{t('archivePurge.days')}</span>
  89. </div>
  90. </div>
  91. <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30 p-3">
  92. <div className="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 mb-2">
  93. {t('archivePurge.effectsTitle')}
  94. </div>
  95. <ul className="text-xs text-gray-700 dark:text-gray-300 space-y-1 list-disc pl-4">
  96. <li>{t('archivePurge.effect1')}</li>
  97. <li>{t('archivePurge.effect2')}</li>
  98. <li>{t('archivePurge.effect3')}</li>
  99. <li>{t('archivePurge.effect4')}</li>
  100. </ul>
  101. </div>
  102. <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 p-3">
  103. {previewQuery.isLoading || previewQuery.isFetching ? (
  104. <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
  105. <Loader2 className="w-4 h-4 animate-spin" /> {t('archivePurge.previewLoading')}
  106. </div>
  107. ) : previewQuery.isError ? (
  108. <div className="text-sm text-red-600 dark:text-red-400">
  109. {(previewQuery.error as Error | null)?.message ?? t('archivePurge.previewFailed')}
  110. </div>
  111. ) : (
  112. <div className="text-sm text-gray-900 dark:text-gray-100">
  113. <div className="font-medium">
  114. {t('archivePurge.previewSummary', { count, size: formatFileSize(totalBytes) })}
  115. </div>
  116. {preview?.sample_filenames && preview.sample_filenames.length > 0 && (
  117. <ul className="mt-2 text-xs text-gray-600 dark:text-gray-400 space-y-0.5 list-disc pl-4">
  118. {preview.sample_filenames.map((name) => (
  119. <li key={name} className="truncate">{name}</li>
  120. ))}
  121. {count > preview.sample_filenames.length && (
  122. <li className="list-none italic text-gray-500">
  123. {t('archivePurge.andMore', { count: count - preview.sample_filenames.length })}
  124. </li>
  125. )}
  126. </ul>
  127. )}
  128. </div>
  129. )}
  130. </div>
  131. <label className="flex gap-2 items-start rounded border border-gray-200 dark:border-gray-700 px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50">
  132. <input
  133. type="checkbox"
  134. checked={purgeStats}
  135. onChange={(e) => setPurgeStats(e.target.checked)}
  136. disabled={purgeMutation.isPending}
  137. className="mt-0.5 shrink-0"
  138. />
  139. <span className="text-xs text-gray-700 dark:text-gray-300">
  140. <span className="font-medium block mb-0.5">{t('archivePurge.purgeStatsLabel')}</span>
  141. <span className="text-gray-500 dark:text-gray-400">{t('archivePurge.purgeStatsHint')}</span>
  142. </span>
  143. </label>
  144. <div className="flex gap-2 items-start text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded px-3 py-2">
  145. <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
  146. <span>{t('archivePurge.warning')}</span>
  147. </div>
  148. </div>
  149. <div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
  150. <Button variant="secondary" onClick={onClose} disabled={purgeMutation.isPending}>
  151. {t('common.cancel')}
  152. </Button>
  153. <Button
  154. variant="danger"
  155. disabled={!canConfirm}
  156. onClick={() => purgeMutation.mutate()}
  157. >
  158. {purgeMutation.isPending ? (
  159. <>
  160. <Loader2 className="w-4 h-4 animate-spin mr-1" />
  161. {t('archivePurge.purging')}
  162. </>
  163. ) : (
  164. t('archivePurge.confirmCta', { count })
  165. )}
  166. </Button>
  167. </div>
  168. </div>
  169. </div>
  170. );
  171. }