import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { ArrowLeft, RotateCcw, Save, Trash2, Loader2 } from 'lucide-react'; import { api } from '../api/client'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; import { formatFileSize } from '../utils/file'; import { parseUTCDate } from '../utils/date'; function formatRelativeDays(iso: string): string { const target = parseUTCDate(iso); if (!target) return ''; const days = Math.ceil((target.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); return days <= 0 ? 'any moment' : days === 1 ? '1 day' : `${days} days`; } function formatDeletedAt(iso: string): string { const date = parseUTCDate(iso); return date ? date.toLocaleString() : iso; } type PendingAction = | { type: 'delete'; id: number; filename: string } | { type: 'empty' } | { type: 'bulkDelete'; count: number } | null; export function LibraryTrashPage() { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, authEnabled } = useAuth(); const [pending, setPending] = useState(null); const [selected, setSelected] = useState>(new Set()); const isAdmin = !authEnabled || hasPermission('library:purge'); const trashQuery = useQuery({ queryKey: ['library-trash'], queryFn: () => api.listLibraryTrash(200, 0), }); const settingsQuery = useQuery({ queryKey: ['library-trash-settings'], queryFn: () => api.getLibraryTrashSettings(), enabled: isAdmin, }); const [retentionDraft, setRetentionDraft] = useState(null); useEffect(() => { if (settingsQuery.data && retentionDraft === null) { setRetentionDraft(settingsQuery.data.retention_days); } }, [settingsQuery.data, retentionDraft]); const updateRetentionMutation = useMutation({ mutationFn: (days: number) => { // Preserve current auto-purge config — this control only touches retention. const current = settingsQuery.data; return api.updateLibraryTrashSettings({ retention_days: days, auto_purge_enabled: current?.auto_purge_enabled ?? false, auto_purge_days: current?.auto_purge_days ?? 90, auto_purge_include_never_printed: current?.auto_purge_include_never_printed ?? true, }); }, onSuccess: (res) => { showToast(t('libraryTrash.toast.retentionSaved', { days: res.retention_days }), 'success'); queryClient.invalidateQueries({ queryKey: ['library-trash-settings'] }); queryClient.invalidateQueries({ queryKey: ['library-trash'] }); }, onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.retentionFailed'), 'error'), }); const restoreMutation = useMutation({ mutationFn: (id: number) => api.restoreLibraryTrash(id), onSuccess: () => { showToast(t('libraryTrash.toast.restored'), 'success'); queryClient.invalidateQueries({ queryKey: ['library-trash'] }); queryClient.invalidateQueries({ queryKey: ['library-trash-count'] }); queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); }, onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.restoreFailed'), 'error'), }); const deleteMutation = useMutation({ mutationFn: (id: number) => api.hardDeleteLibraryTrash(id), onSuccess: () => { showToast(t('libraryTrash.toast.purged'), 'success'); queryClient.invalidateQueries({ queryKey: ['library-trash'] }); queryClient.invalidateQueries({ queryKey: ['library-trash-count'] }); }, onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.purgeFailed'), 'error'), }); const emptyMutation = useMutation({ mutationFn: () => api.emptyLibraryTrash(), onSuccess: (result) => { showToast(t('libraryTrash.toast.emptied', { count: result.deleted }), 'success'); queryClient.invalidateQueries({ queryKey: ['library-trash'] }); queryClient.invalidateQueries({ queryKey: ['library-trash-count'] }); }, onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.emptyFailed'), 'error'), }); // Bulk restore / delete run the existing per-item endpoints in parallel. // The backend has no bulk endpoints (and given typical trash sizes of // dozens of files, spinning up a Promise.all is fast enough that a new // endpoint would be gratuitous). const bulkRestoreMutation = useMutation({ mutationFn: (ids: number[]) => Promise.all(ids.map((id) => api.restoreLibraryTrash(id))), onSuccess: (_, ids) => { showToast(t('libraryTrash.toast.bulkRestored', { count: ids.length }), 'success'); setSelected(new Set()); queryClient.invalidateQueries({ queryKey: ['library-trash'] }); queryClient.invalidateQueries({ queryKey: ['library-trash-count'] }); queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); }, onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.restoreFailed'), 'error'), }); const bulkDeleteMutation = useMutation({ mutationFn: (ids: number[]) => Promise.all(ids.map((id) => api.hardDeleteLibraryTrash(id))), onSuccess: (_, ids) => { showToast(t('libraryTrash.toast.bulkPurged', { count: ids.length }), 'success'); setSelected(new Set()); queryClient.invalidateQueries({ queryKey: ['library-trash'] }); queryClient.invalidateQueries({ queryKey: ['library-trash-count'] }); }, onError: (e: Error) => showToast(e.message || t('libraryTrash.toast.purgeFailed'), 'error'), }); const items = useMemo(() => trashQuery.data?.items ?? [], [trashQuery.data?.items]); const retentionDays = trashQuery.data?.retention_days ?? 30; const totalBytes = useMemo(() => items.reduce((sum, i) => sum + i.file_size, 0), [items]); const allSelected = items.length > 0 && items.every((i) => selected.has(i.id)); const someSelected = selected.size > 0 && !allSelected; const toggleOne = (id: number) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const toggleAll = () => { setSelected((prev) => (prev.size === items.length ? new Set() : new Set(items.map((i) => i.id)))); }; const handleConfirm = () => { if (!pending) return; if (pending.type === 'delete') { deleteMutation.mutate(pending.id); } else if (pending.type === 'bulkDelete') { bulkDeleteMutation.mutate(Array.from(selected)); } else { emptyMutation.mutate(); } setPending(null); }; return (
{t('libraryTrash.backToFiles')}

{t('libraryTrash.title')}

{isAdmin ? t('libraryTrash.subtitleAdmin', { days: retentionDays }) : t('libraryTrash.subtitleUser', { days: retentionDays })}

{items.length > 0 && ( )}
{isAdmin && settingsQuery.data && (
setRetentionDraft(Math.max(1, Math.min(365, parseInt(e.target.value || '0', 10) || 0))) } 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" /> {t('libraryTrash.days')}
)} {trashQuery.isLoading ? (
{t('libraryTrash.loading')}
) : items.length === 0 ? (

{t('libraryTrash.empty')}

) : ( <>
{t('libraryTrash.summary', { count: items.length, size: formatFileSize(totalBytes) })}
{selected.size > 0 && (
{t('libraryTrash.selectionCount', { count: selected.size })}
)}
{isAdmin && } {items.map((item) => ( {isAdmin && ( )} ))}
{ if (el) el.indeterminate = someSelected; }} onChange={toggleAll} aria-label={t('libraryTrash.selectAll')} className="rounded border-gray-300 cursor-pointer" /> {t('libraryTrash.col.filename')} {t('libraryTrash.col.folder')} {t('libraryTrash.col.size')} {t('libraryTrash.col.deleted')} {t('libraryTrash.col.autoPurge')}{t('libraryTrash.col.owner')}{t('libraryTrash.col.actions')}
toggleOne(item.id)} aria-label={t('libraryTrash.selectOne', { filename: item.filename })} className="rounded border-gray-300 cursor-pointer" /> {item.filename} {item.folder_name ?? '—'} {formatFileSize(item.file_size)} {formatDeletedAt(item.deleted_at)} {t('libraryTrash.autoPurgeIn', { when: formatRelativeDays(item.auto_purge_at) })} {item.created_by_username ?? '—'}
)} {pending && ( setPending(null)} onConfirm={handleConfirm} title={ pending.type === 'delete' ? t('libraryTrash.confirm.purgeTitle') : pending.type === 'bulkDelete' ? t('libraryTrash.confirm.bulkPurgeTitle') : t('libraryTrash.confirm.emptyTitle') } message={ pending.type === 'delete' ? t('libraryTrash.confirm.purgeBody', { filename: pending.filename }) : pending.type === 'bulkDelete' ? t('libraryTrash.confirm.bulkPurgeBody', { count: pending.count }) : t('libraryTrash.confirm.emptyBody', { count: items.length }) } confirmText={t('libraryTrash.confirm.cta')} variant="danger" /> )} {/* Small escape hatch in case the user navigated here without auth */} {trashQuery.isError && (
{(trashQuery.error as Error | null)?.message ?? t('libraryTrash.loadError')}
)}
); }