LibraryTrashPage.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { Link, useNavigate } from 'react-router-dom';
  3. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import { ArrowLeft, RotateCcw, Save, Trash2, Loader2 } from 'lucide-react';
  6. import { api } from '../api/client';
  7. import { Button } from '../components/Button';
  8. import { ConfirmModal } from '../components/ConfirmModal';
  9. import { useAuth } from '../contexts/AuthContext';
  10. import { useToast } from '../contexts/ToastContext';
  11. import { formatFileSize } from '../utils/file';
  12. import { parseUTCDate } from '../utils/date';
  13. function formatRelativeDays(iso: string): string {
  14. const target = parseUTCDate(iso);
  15. if (!target) return '';
  16. const days = Math.ceil((target.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
  17. return days <= 0 ? 'any moment' : days === 1 ? '1 day' : `${days} days`;
  18. }
  19. function formatDeletedAt(iso: string): string {
  20. const date = parseUTCDate(iso);
  21. return date ? date.toLocaleString() : iso;
  22. }
  23. type PendingAction =
  24. | { type: 'delete'; id: number; filename: string }
  25. | { type: 'empty' }
  26. | { type: 'bulkDelete'; count: number }
  27. | null;
  28. export function LibraryTrashPage() {
  29. const { t } = useTranslation();
  30. const navigate = useNavigate();
  31. const queryClient = useQueryClient();
  32. const { showToast } = useToast();
  33. const { hasPermission, authEnabled } = useAuth();
  34. const [pending, setPending] = useState<PendingAction>(null);
  35. const [selected, setSelected] = useState<Set<number>>(new Set());
  36. const isAdmin = !authEnabled || hasPermission('library:purge');
  37. const trashQuery = useQuery({
  38. queryKey: ['library-trash'],
  39. queryFn: () => api.listLibraryTrash(200, 0),
  40. });
  41. const settingsQuery = useQuery({
  42. queryKey: ['library-trash-settings'],
  43. queryFn: () => api.getLibraryTrashSettings(),
  44. enabled: isAdmin,
  45. });
  46. const [retentionDraft, setRetentionDraft] = useState<number | null>(null);
  47. useEffect(() => {
  48. if (settingsQuery.data && retentionDraft === null) {
  49. setRetentionDraft(settingsQuery.data.retention_days);
  50. }
  51. }, [settingsQuery.data, retentionDraft]);
  52. const updateRetentionMutation = useMutation({
  53. mutationFn: (days: number) => {
  54. // Preserve current auto-purge config — this control only touches retention.
  55. const current = settingsQuery.data;
  56. return api.updateLibraryTrashSettings({
  57. retention_days: days,
  58. auto_purge_enabled: current?.auto_purge_enabled ?? false,
  59. auto_purge_days: current?.auto_purge_days ?? 90,
  60. auto_purge_include_never_printed: current?.auto_purge_include_never_printed ?? true,
  61. });
  62. },
  63. onSuccess: (res) => {
  64. showToast(t('libraryTrash.toast.retentionSaved', { days: res.retention_days }), 'success');
  65. queryClient.invalidateQueries({ queryKey: ['library-trash-settings'] });
  66. queryClient.invalidateQueries({ queryKey: ['library-trash'] });
  67. },
  68. onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.retentionFailed'), 'error'),
  69. });
  70. const restoreMutation = useMutation({
  71. mutationFn: (id: number) => api.restoreLibraryTrash(id),
  72. onSuccess: () => {
  73. showToast(t('libraryTrash.toast.restored'), 'success');
  74. queryClient.invalidateQueries({ queryKey: ['library-trash'] });
  75. queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
  76. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  77. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  78. },
  79. onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.restoreFailed'), 'error'),
  80. });
  81. const deleteMutation = useMutation({
  82. mutationFn: (id: number) => api.hardDeleteLibraryTrash(id),
  83. onSuccess: () => {
  84. showToast(t('libraryTrash.toast.purged'), 'success');
  85. queryClient.invalidateQueries({ queryKey: ['library-trash'] });
  86. queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
  87. },
  88. onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.purgeFailed'), 'error'),
  89. });
  90. const emptyMutation = useMutation({
  91. mutationFn: () => api.emptyLibraryTrash(),
  92. onSuccess: (result) => {
  93. showToast(t('libraryTrash.toast.emptied', { count: result.deleted }), 'success');
  94. queryClient.invalidateQueries({ queryKey: ['library-trash'] });
  95. queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
  96. },
  97. onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.emptyFailed'), 'error'),
  98. });
  99. // Bulk restore / delete run the existing per-item endpoints in parallel.
  100. // The backend has no bulk endpoints (and given typical trash sizes of
  101. // dozens of files, spinning up a Promise.all is fast enough that a new
  102. // endpoint would be gratuitous).
  103. const bulkRestoreMutation = useMutation({
  104. mutationFn: (ids: number[]) => Promise.all(ids.map((id) => api.restoreLibraryTrash(id))),
  105. onSuccess: (_, ids) => {
  106. showToast(t('libraryTrash.toast.bulkRestored', { count: ids.length }), 'success');
  107. setSelected(new Set());
  108. queryClient.invalidateQueries({ queryKey: ['library-trash'] });
  109. queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
  110. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  111. queryClient.invalidateQueries({ queryKey: ['library-folders'] });
  112. },
  113. onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.restoreFailed'), 'error'),
  114. });
  115. const bulkDeleteMutation = useMutation({
  116. mutationFn: (ids: number[]) => Promise.all(ids.map((id) => api.hardDeleteLibraryTrash(id))),
  117. onSuccess: (_, ids) => {
  118. showToast(t('libraryTrash.toast.bulkPurged', { count: ids.length }), 'success');
  119. setSelected(new Set());
  120. queryClient.invalidateQueries({ queryKey: ['library-trash'] });
  121. queryClient.invalidateQueries({ queryKey: ['library-trash-count'] });
  122. },
  123. onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.purgeFailed'), 'error'),
  124. });
  125. const items = useMemo(() => trashQuery.data?.items ?? [], [trashQuery.data?.items]);
  126. const retentionDays = trashQuery.data?.retention_days ?? 30;
  127. const totalBytes = useMemo(() => items.reduce((sum, i) => sum + i.file_size, 0), [items]);
  128. const allSelected = items.length > 0 && items.every((i) => selected.has(i.id));
  129. const someSelected = selected.size > 0 && !allSelected;
  130. const toggleOne = (id: number) => {
  131. setSelected((prev) => {
  132. const next = new Set(prev);
  133. if (next.has(id)) next.delete(id);
  134. else next.add(id);
  135. return next;
  136. });
  137. };
  138. const toggleAll = () => {
  139. setSelected((prev) => (prev.size === items.length ? new Set() : new Set(items.map((i) => i.id))));
  140. };
  141. const handleConfirm = () => {
  142. if (!pending) return;
  143. if (pending.type === 'delete') {
  144. deleteMutation.mutate(pending.id);
  145. } else if (pending.type === 'bulkDelete') {
  146. bulkDeleteMutation.mutate(Array.from(selected));
  147. } else {
  148. emptyMutation.mutate();
  149. }
  150. setPending(null);
  151. };
  152. return (
  153. <div className="p-6 max-w-screen-2xl mx-auto">
  154. <div className="flex items-center gap-3 mb-4">
  155. <Link
  156. to="/files"
  157. className="inline-flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
  158. >
  159. <ArrowLeft className="w-4 h-4" /> {t('libraryTrash.backToFiles')}
  160. </Link>
  161. </div>
  162. <div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
  163. <div>
  164. <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
  165. {t('libraryTrash.title')}
  166. </h1>
  167. <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
  168. {isAdmin
  169. ? t('libraryTrash.subtitleAdmin', { days: retentionDays })
  170. : t('libraryTrash.subtitleUser', { days: retentionDays })}
  171. </p>
  172. </div>
  173. {items.length > 0 && (
  174. <Button
  175. variant="secondary"
  176. onClick={() => setPending({ type: 'empty' })}
  177. className="text-red-600 dark:text-red-400"
  178. >
  179. <Trash2 className="w-4 h-4 mr-1" />
  180. {t('libraryTrash.emptyTrash')}
  181. </Button>
  182. )}
  183. </div>
  184. {isAdmin && settingsQuery.data && (
  185. <div className="mb-4 border border-gray-200 dark:border-gray-700 rounded-lg p-3 flex items-center gap-3 bg-gray-50 dark:bg-gray-800/40">
  186. <label htmlFor="retention-days" className="text-sm font-medium text-gray-700 dark:text-gray-300">
  187. {t('libraryTrash.retentionLabel')}
  188. </label>
  189. <input
  190. id="retention-days"
  191. type="number"
  192. min={1}
  193. max={365}
  194. value={retentionDraft ?? settingsQuery.data.retention_days}
  195. onChange={(e) =>
  196. setRetentionDraft(Math.max(1, Math.min(365, parseInt(e.target.value || '0', 10) || 0)))
  197. }
  198. className="w-20 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"
  199. />
  200. <span className="text-sm text-gray-600 dark:text-gray-400">{t('libraryTrash.days')}</span>
  201. <Button
  202. variant="secondary"
  203. onClick={() => retentionDraft != null && updateRetentionMutation.mutate(retentionDraft)}
  204. disabled={
  205. updateRetentionMutation.isPending ||
  206. retentionDraft == null ||
  207. retentionDraft === settingsQuery.data.retention_days
  208. }
  209. className="ml-auto"
  210. >
  211. <Save className="w-4 h-4 mr-1" />
  212. {t('common.save')}
  213. </Button>
  214. </div>
  215. )}
  216. {trashQuery.isLoading ? (
  217. <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
  218. <Loader2 className="w-4 h-4 animate-spin" /> {t('libraryTrash.loading')}
  219. </div>
  220. ) : items.length === 0 ? (
  221. <div className="border border-dashed border-gray-300 dark:border-gray-700 rounded-lg p-12 text-center">
  222. <p className="text-gray-500 dark:text-gray-400">{t('libraryTrash.empty')}</p>
  223. </div>
  224. ) : (
  225. <>
  226. <div className="flex items-center justify-between mb-2">
  227. <div className="text-xs text-gray-500 dark:text-gray-400">
  228. {t('libraryTrash.summary', { count: items.length, size: formatFileSize(totalBytes) })}
  229. </div>
  230. {selected.size > 0 && (
  231. <div className="flex items-center gap-2 text-sm">
  232. <span className="text-gray-600 dark:text-gray-400">
  233. {t('libraryTrash.selectionCount', { count: selected.size })}
  234. </span>
  235. <Button
  236. variant="secondary"
  237. onClick={() => bulkRestoreMutation.mutate(Array.from(selected))}
  238. disabled={bulkRestoreMutation.isPending}
  239. >
  240. <RotateCcw className="w-4 h-4 mr-1" />
  241. {t('libraryTrash.bulkRestore')}
  242. </Button>
  243. <Button
  244. variant="secondary"
  245. onClick={() => setPending({ type: 'bulkDelete', count: selected.size })}
  246. disabled={bulkDeleteMutation.isPending}
  247. className="text-red-600 dark:text-red-400"
  248. >
  249. <Trash2 className="w-4 h-4 mr-1" />
  250. {t('libraryTrash.bulkPurge')}
  251. </Button>
  252. </div>
  253. )}
  254. </div>
  255. <div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-x-auto">
  256. <table className="w-full text-sm">
  257. <thead className="bg-gray-50 dark:bg-gray-800 text-left text-gray-600 dark:text-gray-300">
  258. <tr>
  259. <th className="px-3 py-2 w-10">
  260. <input
  261. type="checkbox"
  262. checked={allSelected}
  263. ref={(el) => {
  264. if (el) el.indeterminate = someSelected;
  265. }}
  266. onChange={toggleAll}
  267. aria-label={t('libraryTrash.selectAll')}
  268. className="rounded border-gray-300 cursor-pointer"
  269. />
  270. </th>
  271. <th className="px-3 py-2 font-medium">{t('libraryTrash.col.filename')}</th>
  272. <th className="px-3 py-2 font-medium">{t('libraryTrash.col.folder')}</th>
  273. <th className="px-3 py-2 font-medium text-right">{t('libraryTrash.col.size')}</th>
  274. <th className="px-3 py-2 font-medium whitespace-nowrap">{t('libraryTrash.col.deleted')}</th>
  275. <th className="px-3 py-2 font-medium whitespace-nowrap">{t('libraryTrash.col.autoPurge')}</th>
  276. {isAdmin && <th className="px-3 py-2 font-medium">{t('libraryTrash.col.owner')}</th>}
  277. <th className="px-3 py-2 font-medium text-right">{t('libraryTrash.col.actions')}</th>
  278. </tr>
  279. </thead>
  280. <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
  281. {items.map((item) => (
  282. <tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
  283. <td className="px-3 py-2">
  284. <input
  285. type="checkbox"
  286. checked={selected.has(item.id)}
  287. onChange={() => toggleOne(item.id)}
  288. aria-label={t('libraryTrash.selectOne', { filename: item.filename })}
  289. className="rounded border-gray-300 cursor-pointer"
  290. />
  291. </td>
  292. <td
  293. className="px-3 py-2 text-gray-900 dark:text-gray-100 truncate max-w-md"
  294. title={item.filename}
  295. >
  296. {item.filename}
  297. </td>
  298. <td className="px-3 py-2 text-gray-600 dark:text-gray-400">{item.folder_name ?? '—'}</td>
  299. <td className="px-3 py-2 text-right text-gray-600 dark:text-gray-400 tabular-nums whitespace-nowrap">
  300. {formatFileSize(item.file_size)}
  301. </td>
  302. <td className="px-3 py-2 text-gray-600 dark:text-gray-400 whitespace-nowrap">
  303. {formatDeletedAt(item.deleted_at)}
  304. </td>
  305. <td className="px-3 py-2 text-gray-600 dark:text-gray-400 whitespace-nowrap">
  306. <span title={formatDeletedAt(item.auto_purge_at)}>
  307. {t('libraryTrash.autoPurgeIn', { when: formatRelativeDays(item.auto_purge_at) })}
  308. </span>
  309. </td>
  310. {isAdmin && (
  311. <td className="px-3 py-2 text-gray-600 dark:text-gray-400">
  312. {item.created_by_username ?? '—'}
  313. </td>
  314. )}
  315. <td className="px-3 py-2 text-right whitespace-nowrap">
  316. <button
  317. onClick={() => restoreMutation.mutate(item.id)}
  318. disabled={restoreMutation.isPending}
  319. className="inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
  320. >
  321. <RotateCcw className="w-3.5 h-3.5" />
  322. {t('libraryTrash.restore')}
  323. </button>
  324. <button
  325. onClick={() => setPending({ type: 'delete', id: item.id, filename: item.filename })}
  326. disabled={deleteMutation.isPending}
  327. className="inline-flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 ml-2"
  328. >
  329. <Trash2 className="w-3.5 h-3.5" />
  330. {t('libraryTrash.purgeNow')}
  331. </button>
  332. </td>
  333. </tr>
  334. ))}
  335. </tbody>
  336. </table>
  337. </div>
  338. </>
  339. )}
  340. {pending && (
  341. <ConfirmModal
  342. onCancel={() => setPending(null)}
  343. onConfirm={handleConfirm}
  344. title={
  345. pending.type === 'delete'
  346. ? t('libraryTrash.confirm.purgeTitle')
  347. : pending.type === 'bulkDelete'
  348. ? t('libraryTrash.confirm.bulkPurgeTitle')
  349. : t('libraryTrash.confirm.emptyTitle')
  350. }
  351. message={
  352. pending.type === 'delete'
  353. ? t('libraryTrash.confirm.purgeBody', { filename: pending.filename })
  354. : pending.type === 'bulkDelete'
  355. ? t('libraryTrash.confirm.bulkPurgeBody', { count: pending.count })
  356. : t('libraryTrash.confirm.emptyBody', { count: items.length })
  357. }
  358. confirmText={t('libraryTrash.confirm.cta')}
  359. variant="danger"
  360. />
  361. )}
  362. {/* Small escape hatch in case the user navigated here without auth */}
  363. {trashQuery.isError && (
  364. <div className="mt-4 text-sm text-red-600 dark:text-red-400">
  365. {(trashQuery.error as Error | null)?.message ?? t('libraryTrash.loadError')}
  366. <Button variant="secondary" onClick={() => navigate('/files')} className="ml-3">
  367. {t('libraryTrash.backToFiles')}
  368. </Button>
  369. </div>
  370. )}
  371. </div>
  372. );
  373. }