mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
feat(#1008): archive auto-purge + dedicated archives:purge permission
Adds an archive counterpart to the library trash sweeper shipped in the
previous commit. Unlike the library flow, archives are hard-deleted —
print history is a decaying timeline, so there is no trash intermediate;
download or favourite anything you want to keep first.
Backend
- New ArchivePurgeService (backend/app/services/archive_purge.py) with
its own 15-minute scheduler loop and a 24h throttle on actual purge
runs. Delegates every delete to the existing safety-checked
ArchiveService.delete_archive so the 3MF, thumbnail, timelapse, source
3MF, F3D, and photo folder all get cleaned up together with the DB
row. Per-row session via async_session() avoids commit-per-row churn
on any caller-passed session.
- New /archives/purge/{preview,settings} + POST /archives/purge routes
gated on a dedicated archives:purge permission (not archives:delete_all)
so admins can delegate bulk-delete to a role without granting
per-archive delete on other users' rows.
- seed_default_groups() now backfills both library:purge and
archives:purge on the Administrators group for upgraded installs —
the original library:purge was added after Administrators was first
seeded so the "create if not exists" path skipped existing DBs and
left admins without the permission.
- 8 new integration tests (defaults, settings roundtrip, bound
validation, preview, manual purge, auto-purge enabled path, 24h
throttle, disabled skip).
Frontend
- Settings → Archives card gains an auto-purge toggle + age input (7d
floor, 10y ceiling, 365d default), with a save-toast on every change.
The bulk "Purge old" button lives on the Archives page header
(rightmost, after Upload 3MF) to match the File Manager pattern —
configuration in Settings, one-shot action on the page.
- New PurgeArchivesModal mirrors PurgeOldFilesModal: live preview (count
+ total size freed + sample filenames) debounced at 300ms, amber
"hard-delete, no undo" warning.
- Admin-only UI gates on archives:purge via the standard hasPermission
hook; Permission TS union updated.
- i18n blocks across all 8 locales (en/de full, other 6 English
fallback per project convention).
Docs
- CHANGELOG entry under 0.2.4b1 following the existing library-trash
entry.
- bambuddy-wiki archiving.md gains a new "Auto-Purge" section.
- bambuddy-website features.html gets a matching bullet.
Verification: python -m ruff check backend/app/ clean; 25 integration
tests pass (8 archive_purge + 17 library_trash regression); npm run
build clean.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { api } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatFileSize } from '../utils/file';
|
||||
|
||||
interface PurgeArchivesModalProps {
|
||||
onClose: () => void;
|
||||
initialDays?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_DAYS = 365;
|
||||
|
||||
export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [days, setDays] = useState(initialDays ?? DEFAULT_DAYS);
|
||||
|
||||
const [debouncedDays, setDebouncedDays] = useState(days);
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => setDebouncedDays(days), 300);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [days]);
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['archive-purge-preview', debouncedDays],
|
||||
queryFn: () => api.previewArchivePurge(debouncedDays),
|
||||
enabled: debouncedDays >= 1,
|
||||
});
|
||||
|
||||
const purgeMutation = useMutation({
|
||||
mutationFn: () => api.executeArchivePurge(days),
|
||||
onSuccess: (res) => {
|
||||
showToast(t('archivePurge.toast.success', { count: res.deleted }), 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['archive-stats'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message || t('archivePurge.toast.failed'), 'error'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !purgeMutation.isPending) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose, purgeMutation.isPending]);
|
||||
|
||||
const preview = previewQuery.data;
|
||||
const count = preview?.count ?? 0;
|
||||
const totalBytes = preview?.total_bytes ?? 0;
|
||||
const canConfirm = count > 0 && !purgeMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-lg w-full">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
{t('archivePurge.title')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={t('common.close')}
|
||||
disabled={purgeMutation.isPending}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('archivePurge.description')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="archive-purge-days" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('archivePurge.ageLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="archive-purge-days"
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={days}
|
||||
onChange={(e) => setDays(Math.max(1, Math.min(3650, parseInt(e.target.value || '0', 10) || 0)))}
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{t('archivePurge.days')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 p-3">
|
||||
{previewQuery.isLoading || previewQuery.isFetching ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> {t('archivePurge.previewLoading')}
|
||||
</div>
|
||||
) : previewQuery.isError ? (
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
{(previewQuery.error as Error | null)?.message ?? t('archivePurge.previewFailed')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<div className="font-medium">
|
||||
{t('archivePurge.previewSummary', { count, size: formatFileSize(totalBytes) })}
|
||||
</div>
|
||||
{preview?.sample_filenames && preview.sample_filenames.length > 0 && (
|
||||
<ul className="mt-2 text-xs text-gray-600 dark:text-gray-400 space-y-0.5 list-disc pl-4">
|
||||
{preview.sample_filenames.map((name) => (
|
||||
<li key={name} className="truncate">{name}</li>
|
||||
))}
|
||||
{count > preview.sample_filenames.length && (
|
||||
<li className="list-none italic text-gray-500">
|
||||
{t('archivePurge.andMore', { count: count - preview.sample_filenames.length })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>{t('archivePurge.warning')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button variant="secondary" onClick={onClose} disabled={purgeMutation.isPending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={!canConfirm}
|
||||
onClick={() => purgeMutation.mutate()}
|
||||
>
|
||||
{purgeMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
||||
{t('archivePurge.purging')}
|
||||
</>
|
||||
) : (
|
||||
t('archivePurge.confirmCta', { count })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user