PurgeArchivesModal.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  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. // The age the shown preview was made for, never one still being typed.
  34. mutationFn: () => api.executeArchivePurge(debouncedDays, purgeStats),
  35. onSuccess: (res) => {
  36. showToast(t('archivePurge.toast.success', { count: res.deleted }), 'success');
  37. queryClient.invalidateQueries({ queryKey: ['archives'] });
  38. queryClient.invalidateQueries({ queryKey: ['archive-stats'] });
  39. onClose();
  40. },
  41. onError: (e: Error) => showToast(e.message || t('archivePurge.toast.failed'), 'error'),
  42. });
  43. useEffect(() => {
  44. const handleKey = (e: KeyboardEvent) => {
  45. if (e.key === 'Escape' && !purgeMutation.isPending) onClose();
  46. };
  47. window.addEventListener('keydown', handleKey);
  48. return () => window.removeEventListener('keydown', handleKey);
  49. }, [onClose, purgeMutation.isPending]);
  50. const preview = previewQuery.data;
  51. const count = preview?.count ?? 0;
  52. const totalBytes = preview?.total_bytes ?? 0;
  53. // Only once the preview matches the field: the button shows its count.
  54. const canConfirm = count > 0 && days === debouncedDays && !previewQuery.isFetching && !purgeMutation.isPending;
  55. return (
  56. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
  57. <div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-lg w-full">
  58. <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
  59. <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
  60. <Trash2 className="w-5 h-5" />
  61. {t('archivePurge.title')}
  62. </h2>
  63. <button
  64. onClick={onClose}
  65. className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
  66. aria-label={t('common.close')}
  67. disabled={purgeMutation.isPending}
  68. >
  69. <X className="w-5 h-5" />
  70. </button>
  71. </div>
  72. <div className="p-4 space-y-4">
  73. <p className="text-sm text-gray-600 dark:text-gray-400">
  74. {t('archivePurge.description')}
  75. </p>
  76. <div>
  77. <label htmlFor="archive-purge-days" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
  78. {t('archivePurge.ageLabel')}
  79. </label>
  80. <div className="flex items-center gap-3">
  81. <input
  82. id="archive-purge-days"
  83. type="number"
  84. min={1}
  85. max={3650}
  86. value={days}
  87. onChange={(e) => setDays(Math.max(1, Math.min(3650, parseInt(e.target.value || '0', 10) || 0)))}
  88. 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"
  89. />
  90. <span className="text-sm text-gray-600 dark:text-gray-400">{t('archivePurge.days')}</span>
  91. </div>
  92. </div>
  93. <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30 p-3">
  94. <div className="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 mb-2">
  95. {t('archivePurge.effectsTitle')}
  96. </div>
  97. <ul className="text-xs text-gray-700 dark:text-gray-300 space-y-1 list-disc pl-4">
  98. <li>{t('archivePurge.effect1')}</li>
  99. <li>{t('archivePurge.effect2')}</li>
  100. <li>{t('archivePurge.effect3')}</li>
  101. <li>{t('archivePurge.effect4')}</li>
  102. </ul>
  103. </div>
  104. <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 p-3">
  105. {previewQuery.isLoading || previewQuery.isFetching ? (
  106. <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
  107. <Loader2 className="w-4 h-4 animate-spin" /> {t('archivePurge.previewLoading')}
  108. </div>
  109. ) : previewQuery.isError ? (
  110. <div className="text-sm text-red-600 dark:text-red-400">
  111. {(previewQuery.error as Error | null)?.message ?? t('archivePurge.previewFailed')}
  112. </div>
  113. ) : (
  114. <div className="text-sm text-gray-900 dark:text-gray-100">
  115. <div className="font-medium">
  116. {t('archivePurge.previewSummary', { count, size: formatFileSize(totalBytes) })}
  117. </div>
  118. {preview?.sample_filenames && preview.sample_filenames.length > 0 && (
  119. <ul className="mt-2 text-xs text-gray-600 dark:text-gray-400 space-y-0.5 list-disc pl-4">
  120. {preview.sample_filenames.map((name) => (
  121. <li key={name} className="truncate">{name}</li>
  122. ))}
  123. {count > preview.sample_filenames.length && (
  124. <li className="list-none italic text-gray-500">
  125. {t('archivePurge.andMore', { count: count - preview.sample_filenames.length })}
  126. </li>
  127. )}
  128. </ul>
  129. )}
  130. </div>
  131. )}
  132. </div>
  133. <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">
  134. <input
  135. type="checkbox"
  136. checked={purgeStats}
  137. onChange={(e) => setPurgeStats(e.target.checked)}
  138. disabled={purgeMutation.isPending}
  139. className="mt-0.5 shrink-0"
  140. />
  141. <span className="text-xs text-gray-700 dark:text-gray-300">
  142. <span className="font-medium block mb-0.5">{t('archivePurge.purgeStatsLabel')}</span>
  143. <span className="text-gray-500 dark:text-gray-400">{t('archivePurge.purgeStatsHint')}</span>
  144. </span>
  145. </label>
  146. <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">
  147. <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
  148. <span>{t('archivePurge.warning')}</span>
  149. </div>
  150. </div>
  151. <div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
  152. <Button variant="secondary" onClick={onClose} disabled={purgeMutation.isPending}>
  153. {t('common.cancel')}
  154. </Button>
  155. <Button
  156. variant="danger"
  157. disabled={!canConfirm}
  158. onClick={() => purgeMutation.mutate()}
  159. >
  160. {purgeMutation.isPending ? (
  161. <>
  162. <Loader2 className="w-4 h-4 animate-spin mr-1" />
  163. {t('archivePurge.purging')}
  164. </>
  165. ) : (
  166. t('archivePurge.confirmCta', { count })
  167. )}
  168. </Button>
  169. </div>
  170. </div>
  171. </div>
  172. );
  173. }