PurgeOldFilesModal.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  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 PurgeOldFilesModalProps {
  10. onClose: () => void;
  11. }
  12. const DEFAULT_DAYS = 90;
  13. export function PurgeOldFilesModal({ onClose }: PurgeOldFilesModalProps) {
  14. const { t } = useTranslation();
  15. const queryClient = useQueryClient();
  16. const { showToast } = useToast();
  17. const [days, setDays] = useState(DEFAULT_DAYS);
  18. const [includeNeverPrinted, setIncludeNeverPrinted] = useState(true);
  19. // Debounce the preview query so dragging a slider isn't a DoS.
  20. const [debouncedDays, setDebouncedDays] = useState(days);
  21. useEffect(() => {
  22. const handle = window.setTimeout(() => setDebouncedDays(days), 300);
  23. return () => window.clearTimeout(handle);
  24. }, [days]);
  25. const previewQuery = useQuery({
  26. queryKey: ['library-purge-preview', debouncedDays, includeNeverPrinted],
  27. queryFn: () => api.previewLibraryPurge(debouncedDays, includeNeverPrinted),
  28. enabled: debouncedDays >= 1,
  29. });
  30. const purgeMutation = useMutation({
  31. mutationFn: () => api.executeLibraryPurge(days, includeNeverPrinted),
  32. onSuccess: (res) => {
  33. showToast(t('libraryPurge.toast.success', { count: res.moved_to_trash }), 'success');
  34. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  35. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  36. queryClient.invalidateQueries({ queryKey: ['library-trash'] });
  37. queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
  38. onClose();
  39. },
  40. onError: (e: Error) => showToast(e.message || t('libraryPurge.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('libraryPurge.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('libraryPurge.description')}
  73. </p>
  74. <div>
  75. <label htmlFor="purge-days" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
  76. {t('libraryPurge.ageLabel')}
  77. </label>
  78. <div className="flex items-center gap-3">
  79. <input
  80. id="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('libraryPurge.days')}</span>
  89. </div>
  90. </div>
  91. <label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
  92. <input
  93. type="checkbox"
  94. checked={includeNeverPrinted}
  95. onChange={(e) => setIncludeNeverPrinted(e.target.checked)}
  96. className="rounded border-gray-300"
  97. />
  98. {t('libraryPurge.includeNeverPrinted')}
  99. </label>
  100. <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30 p-3">
  101. <div className="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 mb-2">
  102. {t('libraryPurge.effectsTitle')}
  103. </div>
  104. <ul className="text-xs text-gray-700 dark:text-gray-300 space-y-1 list-disc pl-4">
  105. <li>{t('libraryPurge.effect1')}</li>
  106. <li>{t('libraryPurge.effect2')}</li>
  107. <li>{t('libraryPurge.effect3')}</li>
  108. <li>{t('libraryPurge.effect4')}</li>
  109. </ul>
  110. </div>
  111. <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 p-3">
  112. {previewQuery.isLoading || previewQuery.isFetching ? (
  113. <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
  114. <Loader2 className="w-4 h-4 animate-spin" /> {t('libraryPurge.previewLoading')}
  115. </div>
  116. ) : previewQuery.isError ? (
  117. <div className="text-sm text-red-600 dark:text-red-400">
  118. {(previewQuery.error as Error | null)?.message ?? t('libraryPurge.previewFailed')}
  119. </div>
  120. ) : (
  121. <div className="text-sm text-gray-900 dark:text-gray-100">
  122. <div className="font-medium">
  123. {t('libraryPurge.previewSummary', { count, size: formatFileSize(totalBytes) })}
  124. </div>
  125. {preview?.sample_filenames && preview.sample_filenames.length > 0 && (
  126. <ul className="mt-2 text-xs text-gray-600 dark:text-gray-400 space-y-0.5 list-disc pl-4">
  127. {preview.sample_filenames.map((name) => (
  128. <li key={name} className="truncate">{name}</li>
  129. ))}
  130. {count > preview.sample_filenames.length && (
  131. <li className="list-none italic text-gray-500">
  132. {t('libraryPurge.andMore', { count: count - preview.sample_filenames.length })}
  133. </li>
  134. )}
  135. </ul>
  136. )}
  137. </div>
  138. )}
  139. </div>
  140. <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">
  141. <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
  142. <span>{t('libraryPurge.warning')}</span>
  143. </div>
  144. </div>
  145. <div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
  146. <Button variant="secondary" onClick={onClose} disabled={purgeMutation.isPending}>
  147. {t('common.cancel')}
  148. </Button>
  149. <Button
  150. variant="danger"
  151. disabled={!canConfirm}
  152. onClick={() => purgeMutation.mutate()}
  153. >
  154. {purgeMutation.isPending ? (
  155. <>
  156. <Loader2 className="w-4 h-4 animate-spin mr-1" />
  157. {t('libraryPurge.purging')}
  158. </>
  159. ) : (
  160. t('libraryPurge.confirmCta', { count })
  161. )}
  162. </Button>
  163. </div>
  164. </div>
  165. </div>
  166. );
  167. }